diff --git a/api/admin/users.ts b/api/admin/users.ts new file mode 100644 index 0000000..47583d5 --- /dev/null +++ b/api/admin/users.ts @@ -0,0 +1,219 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { Pool } from 'pg'; +import jwt from 'jsonwebtoken'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, +}); + +// Middleware to verify admin access +async function verifyAdmin(req: NextRequest) { + const authHeader = req.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw new Error('No token provided'); + } + + const token = authHeader.substring(7); + const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production'; + + try { + const decoded = jwt.verify(token, jwtSecret) as any; + + // Check if user has admin role + if (!decoded.roles?.includes('admin')) { + throw new Error('Admin access required'); + } + + return decoded; + } catch (error) { + throw new Error('Invalid token or insufficient permissions'); + } +} + +export default async function handler(req: NextRequest) { + const client = await pool.connect(); + + try { + // Verify admin access + await verifyAdmin(req); + + if (req.method === 'GET') { + // Get all users with their roles + const { searchParams } = new URL(req.url); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '20'); + const search = searchParams.get('search') || ''; + const offset = (page - 1) * limit; + + let whereClause = ''; + let queryParams: any[] = [limit, offset]; + + if (search) { + whereClause = 'WHERE u.username ILIKE $3 OR u.email ILIKE $3 OR u.first_name ILIKE $3 OR u.last_name ILIKE $3'; + queryParams.push(`%${search}%`); + } + + const usersResult = await client.query(` + SELECT + u.id, u.username, u.email, u.first_name, u.last_name, + u.is_active, u.email_verified, u.created_at, u.last_login, + array_agg(r.name) as roles + FROM users u + LEFT JOIN user_roles ur ON u.id = ur.user_id + LEFT JOIN roles r ON ur.role_id = r.id + ${whereClause} + GROUP BY u.id, u.username, u.email, u.first_name, u.last_name, + u.is_active, u.email_verified, u.created_at, u.last_login + ORDER BY u.created_at DESC + LIMIT $1 OFFSET $2 + `, queryParams); + + // Get total count for pagination + const countResult = await client.query(` + SELECT COUNT(DISTINCT u.id) as total + FROM users u + ${whereClause.replace('$3', search ? '$1' : '')} + `, search ? [`%${search}%`] : []); + + return new NextResponse(JSON.stringify({ + success: true, + users: usersResult.rows.map(user => ({ + ...user, + roles: user.roles.filter(Boolean) // Remove null values + })), + pagination: { + page, + limit, + total: parseInt(countResult.rows[0].total), + totalPages: Math.ceil(countResult.rows[0].total / limit) + } + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } else if (req.method === 'PUT') { + // Update user (activate/deactivate, change roles) + const { searchParams } = new URL(req.url); + const userId = searchParams.get('id'); + + if (!userId) { + return new NextResponse(JSON.stringify({ error: 'User ID required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const body = await req.json(); + const { isActive, roles } = body; + + // Update user status + if (typeof isActive === 'boolean') { + await client.query( + 'UPDATE users SET is_active = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [isActive, userId] + ); + } + + // Update user roles + if (roles && Array.isArray(roles)) { + // Remove existing roles + await client.query('DELETE FROM user_roles WHERE user_id = $1', [userId]); + + // Add new roles + for (const roleName of roles) { + const roleResult = await client.query('SELECT id FROM roles WHERE name = $1', [roleName]); + if (roleResult.rows.length > 0) { + await client.query( + 'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)', + [userId, roleResult.rows[0].id] + ); + } + } + } + + // Get updated user data + const updatedUser = await client.query(` + SELECT + u.id, u.username, u.email, u.first_name, u.last_name, + u.is_active, u.email_verified, u.created_at, u.last_login, + array_agg(r.name) as roles + FROM users u + LEFT JOIN user_roles ur ON u.id = ur.user_id + LEFT JOIN roles r ON ur.role_id = r.id + WHERE u.id = $1 + GROUP BY u.id, u.username, u.email, u.first_name, u.last_name, + u.is_active, u.email_verified, u.created_at, u.last_login + `, [userId]); + + return new NextResponse(JSON.stringify({ + success: true, + message: 'User updated successfully', + user: { + ...updatedUser.rows[0], + roles: updatedUser.rows[0].roles.filter(Boolean) + } + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } else if (req.method === 'DELETE') { + // Delete user (soft delete by deactivating) + const { searchParams } = new URL(req.url); + const userId = searchParams.get('id'); + + if (!userId) { + return new NextResponse(JSON.stringify({ error: 'User ID required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + await client.query( + 'UPDATE users SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE id = $1', + [userId] + ); + + return new NextResponse(JSON.stringify({ + success: true, + message: 'User deactivated successfully' + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } else { + return new NextResponse(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + + } catch (error) { + console.error('Admin users API error:', error); + + if ((error as Error).message.includes('Admin access required') || + (error as Error).message.includes('No token provided') || + (error as Error).message.includes('Invalid token')) { + return new NextResponse(JSON.stringify({ + error: 'Unauthorized', + message: (error as Error).message + }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new NextResponse(JSON.stringify({ + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/api/auth/login.ts b/api/auth/login.ts new file mode 100644 index 0000000..69f0e2a --- /dev/null +++ b/api/auth/login.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { Pool } from 'pg'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, +}); + +interface LoginRequest { + username: string; + password: string; +} + +export default async function handler(req: NextRequest) { + if (req.method !== 'POST') { + return new NextResponse(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const client = await pool.connect(); + + try { + const body: LoginRequest = await req.json(); + const { username, password } = body; + + // Validate input + if (!username || !password) { + return new NextResponse(JSON.stringify({ + error: 'Username and password are required' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Get user by username or email + const userResult = await client.query(` + SELECT id, username, email, password_hash, first_name, last_name, is_active, last_login + FROM users + WHERE (username = $1 OR email = $1) AND is_active = true + `, [username]); + + if (userResult.rows.length === 0) { + return new NextResponse(JSON.stringify({ + error: 'Invalid credentials' + }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const user = userResult.rows[0]; + + // Verify password + const isValidPassword = await bcrypt.compare(password, user.password_hash); + if (!isValidPassword) { + return new NextResponse(JSON.stringify({ + error: 'Invalid credentials' + }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Get user roles and permissions + const userRoles = await client.query(` + SELECT r.name, r.description, + array_agg(p.name) as permissions + FROM roles r + JOIN user_roles ur ON r.id = ur.role_id + LEFT JOIN role_permissions rp ON r.id = rp.role_id + LEFT JOIN permissions p ON rp.permission_id = p.id + WHERE ur.user_id = $1 + GROUP BY r.id, r.name, r.description + `, [user.id]); + + const roles = userRoles.rows.map(r => r.name); + const permissions = [...new Set(userRoles.rows.flatMap(r => r.permissions || []))]; + + // Generate JWT token + const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production'; + const token = jwt.sign( + { + userId: user.id, + username: user.username, + email: user.email, + roles, + permissions + }, + jwtSecret, + { expiresIn: '7d' } + ); + + // Store session + const tokenHash = await bcrypt.hash(token, 10); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + await client.query(` + INSERT INTO user_sessions (user_id, token_hash, expires_at, user_agent, ip_address) + VALUES ($1, $2, $3, $4, $5) + `, [ + user.id, + tokenHash, + expiresAt, + req.headers.get('user-agent') || null, + req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || null + ]); + + // Update last login + await client.query( + 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1', + [user.id] + ); + + return new NextResponse(JSON.stringify({ + success: true, + message: 'Login successful', + user: { + id: user.id, + username: user.username, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + roles, + permissions, + lastLogin: user.last_login + }, + token + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('Login error:', error); + return new NextResponse(JSON.stringify({ + error: 'Login failed', + details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/api/auth/register.ts b/api/auth/register.ts new file mode 100644 index 0000000..5f49fe7 --- /dev/null +++ b/api/auth/register.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { Pool } from 'pg'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, +}); + +interface RegisterRequest { + username: string; + email: string; + password: string; + firstName?: string; + lastName?: string; +} + +export default async function handler(req: NextRequest) { + if (req.method !== 'POST') { + return new NextResponse(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const client = await pool.connect(); + + try { + const body: RegisterRequest = await req.json(); + const { username, email, password, firstName, lastName } = body; + + // Validate input + if (!username || !email || !password) { + return new NextResponse(JSON.stringify({ + error: 'Username, email, and password are required' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (password.length < 6) { + return new NextResponse(JSON.stringify({ + error: 'Password must be at least 6 characters long' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check if user already exists + const existingUser = await client.query( + 'SELECT id FROM users WHERE username = $1 OR email = $2', + [username, email] + ); + + if (existingUser.rows.length > 0) { + return new NextResponse(JSON.stringify({ + error: 'Username or email already exists' + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Hash password + const saltRounds = 12; + const passwordHash = await bcrypt.hash(password, saltRounds); + + // Create user + const userResult = await client.query(` + INSERT INTO users (username, email, password_hash, first_name, last_name) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, username, email, first_name, last_name, created_at + `, [username, email, passwordHash, firstName || null, lastName || null]); + + const user = userResult.rows[0]; + + // Assign default 'user' role + const roleResult = await client.query( + 'SELECT id FROM roles WHERE name = $1', + ['user'] + ); + + if (roleResult.rows.length > 0) { + await client.query( + 'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)', + [user.id, roleResult.rows[0].id] + ); + } + + // Generate JWT token + const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production'; + const token = jwt.sign( + { + userId: user.id, + username: user.username, + email: user.email + }, + jwtSecret, + { expiresIn: '7d' } + ); + + // Store session + const tokenHash = await bcrypt.hash(token, 10); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + await client.query(` + INSERT INTO user_sessions (user_id, token_hash, expires_at, user_agent, ip_address) + VALUES ($1, $2, $3, $4, $5) + `, [ + user.id, + tokenHash, + expiresAt, + req.headers.get('user-agent') || null, + req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || null + ]); + + // Get user roles for response + const userRoles = await client.query(` + SELECT r.name, r.description + FROM roles r + JOIN user_roles ur ON r.id = ur.role_id + WHERE ur.user_id = $1 + `, [user.id]); + + return new NextResponse(JSON.stringify({ + success: true, + message: 'User registered successfully', + user: { + id: user.id, + username: user.username, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + roles: userRoles.rows.map(r => r.name), + createdAt: user.created_at + }, + token + }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('Registration error:', error); + return new NextResponse(JSON.stringify({ + error: 'Registration failed', + details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/api/setup-auth.ts b/api/setup-auth.ts new file mode 100644 index 0000000..1e1e38b --- /dev/null +++ b/api/setup-auth.ts @@ -0,0 +1,207 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { Pool } from 'pg'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, +}); + +export default async function handler(req: NextRequest) { + if (req.method !== 'POST') { + return new NextResponse(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const client = await pool.connect(); + + try { + console.log('Setting up user authentication schema...'); + + // Execute the schema setup + await client.query(` + -- Users table + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(50), + last_name VARCHAR(50), + avatar_url TEXT, + is_active BOOLEAN DEFAULT true, + email_verified BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP WITH TIME ZONE + ); + + -- Roles table + CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + + -- User roles junction table (many-to-many) + CREATE TABLE IF NOT EXISTS user_roles ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + assigned_by INTEGER REFERENCES users(id), + UNIQUE(user_id, role_id) + ); + + -- Permissions table + CREATE TABLE IF NOT EXISTS permissions ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + resource VARCHAR(50), + action VARCHAR(50), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + + -- Role permissions junction table + CREATE TABLE IF NOT EXISTS role_permissions ( + id SERIAL PRIMARY KEY, + role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + permission_id INTEGER REFERENCES permissions(id) ON DELETE CASCADE, + UNIQUE(role_id, permission_id) + ); + + -- Sessions table for JWT token management + CREATE TABLE IF NOT EXISTS user_sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_used TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + user_agent TEXT, + ip_address INET + ); + `); + + // Update existing tables to include user ownership + await client.query(` + ALTER TABLE user_collections ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(id); + ALTER TABLE user_decks ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(id); + `); + + // Insert default roles + await client.query(` + INSERT INTO roles (name, description) VALUES + ('user', 'Standard user with basic permissions'), + ('admin', 'Administrator with full system access') + ON CONFLICT (name) DO NOTHING; + `); + + // Insert default permissions + const permissions = [ + ['cards.read', 'View cards', 'cards', 'read'], + ['cards.create', 'Create new cards', 'cards', 'create'], + ['cards.update', 'Update existing cards', 'cards', 'update'], + ['cards.delete', 'Delete cards', 'cards', 'delete'], + ['collections.read', 'View collections', 'collections', 'read'], + ['collections.create', 'Create collections', 'collections', 'create'], + ['collections.update', 'Update collections', 'collections', 'update'], + ['collections.delete', 'Delete collections', 'collections', 'delete'], + ['decks.read', 'View decks', 'decks', 'read'], + ['decks.create', 'Create decks', 'decks', 'create'], + ['decks.update', 'Update decks', 'decks', 'update'], + ['decks.delete', 'Delete decks', 'decks', 'delete'], + ['users.read', 'View users', 'users', 'read'], + ['users.create', 'Create users', 'users', 'create'], + ['users.update', 'Update users', 'users', 'update'], + ['users.delete', 'Delete users', 'users', 'delete'], + ['admin.access', 'Access admin panel', 'admin', 'access'] + ]; + + for (const [name, description, resource, action] of permissions) { + await client.query(` + INSERT INTO permissions (name, description, resource, action) + VALUES ($1, $2, $3, $4) + ON CONFLICT (name) DO NOTHING; + `, [name, description, resource, action]); + } + + // Assign permissions to roles + // User role permissions + await client.query(` + INSERT INTO role_permissions (role_id, permission_id) + SELECT r.id, p.id + FROM roles r, permissions p + WHERE r.name = 'user' + AND p.name IN ( + 'cards.read', + 'collections.read', 'collections.create', 'collections.update', 'collections.delete', + 'decks.read', 'decks.create', 'decks.update', 'decks.delete' + ) + ON CONFLICT (role_id, permission_id) DO NOTHING; + `); + + // Admin role permissions (all permissions) + await client.query(` + INSERT INTO role_permissions (role_id, permission_id) + SELECT r.id, p.id + FROM roles r, permissions p + WHERE r.name = 'admin' + ON CONFLICT (role_id, permission_id) DO NOTHING; + `); + + // Create indexes for performance + await client.query(` + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); + CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at); + `); + + // Create updated_at trigger function + await client.query(` + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ language 'plpgsql'; + `); + + // Add updated_at triggers + await client.query(` + DROP TRIGGER IF EXISTS update_users_updated_at ON users; + CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + `); + + console.log('✅ User authentication schema setup completed!'); + + return new NextResponse(JSON.stringify({ + success: true, + message: 'User authentication schema setup completed successfully' + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('Schema setup failed:', error); + return new NextResponse(JSON.stringify({ + success: false, + error: 'Schema setup failed', + details: (error as Error).message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5d485ac..9e07067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "@types/bcryptjs": "^2.4.6", "@types/jest": "^27.5.2", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^16.18.126", "@types/pg": "^8.15.4", "@types/react": "^19.1.8", @@ -24,6 +26,8 @@ "@vercel/blob": "^1.1.1", "@vercel/speed-insights": "^1.2.0", "axios": "^1.10.0", + "bcryptjs": "^3.0.2", + "jsonwebtoken": "^9.0.2", "pg": "^8.16.3", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -39,7 +43,7 @@ "tailwindcss": "^3.4.17" }, "engines": { - "node": "20.x" + "node": "18.x" } }, "node_modules/@adobe/css-tools": { @@ -6442,6 +6446,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -6624,12 +6634,28 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "16.18.126", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", @@ -8451,6 +8477,12 @@ "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -10057,6 +10089,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -14436,6 +14477,28 @@ "node": ">=0.10.0" } }, + "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/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -14451,6 +14514,27 @@ "node": ">=4.0" } }, + "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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -14599,6 +14683,42 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "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.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -14611,6 +14731,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "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/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", diff --git a/package.json b/package.json index 7dc3a93..6d6a143 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "@types/bcryptjs": "^2.4.6", "@types/jest": "^27.5.2", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^16.18.126", "@types/pg": "^8.15.4", "@types/react": "^19.1.8", @@ -22,6 +24,8 @@ "@vercel/blob": "^1.1.1", "@vercel/speed-insights": "^1.2.0", "axios": "^1.10.0", + "bcryptjs": "^3.0.2", + "jsonwebtoken": "^9.0.2", "pg": "^8.16.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/scripts/user-auth-schema.sql b/scripts/user-auth-schema.sql new file mode 100644 index 0000000..db1d5a4 --- /dev/null +++ b/scripts/user-auth-schema.sql @@ -0,0 +1,138 @@ +-- User Authentication and Role Management Schema + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(50), + last_name VARCHAR(50), + avatar_url TEXT, + is_active BOOLEAN DEFAULT true, + email_verified BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP WITH TIME ZONE +); + +-- Roles table +CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- User roles junction table (many-to-many) +CREATE TABLE IF NOT EXISTS user_roles ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + assigned_by INTEGER REFERENCES users(id), + UNIQUE(user_id, role_id) +); + +-- Permissions table +CREATE TABLE IF NOT EXISTS permissions ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + resource VARCHAR(50), -- cards, users, decks, etc. + action VARCHAR(50), -- create, read, update, delete + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Role permissions junction table +CREATE TABLE IF NOT EXISTS role_permissions ( + id SERIAL PRIMARY KEY, + role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + permission_id INTEGER REFERENCES permissions(id) ON DELETE CASCADE, + UNIQUE(role_id, permission_id) +); + +-- Sessions table for JWT token management +CREATE TABLE IF NOT EXISTS user_sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_used TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + user_agent TEXT, + ip_address INET +); + +-- Update existing tables to include user ownership +ALTER TABLE user_collections ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(id); +ALTER TABLE user_decks ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(id); + +-- Insert default roles +INSERT INTO roles (name, description) VALUES + ('user', 'Standard user with basic permissions'), + ('admin', 'Administrator with full system access') +ON CONFLICT (name) DO NOTHING; + +-- Insert default permissions +INSERT INTO permissions (name, description, resource, action) VALUES + ('cards.read', 'View cards', 'cards', 'read'), + ('cards.create', 'Create new cards', 'cards', 'create'), + ('cards.update', 'Update existing cards', 'cards', 'update'), + ('cards.delete', 'Delete cards', 'cards', 'delete'), + ('collections.read', 'View collections', 'collections', 'read'), + ('collections.create', 'Create collections', 'collections', 'create'), + ('collections.update', 'Update collections', 'collections', 'update'), + ('collections.delete', 'Delete collections', 'collections', 'delete'), + ('decks.read', 'View decks', 'decks', 'read'), + ('decks.create', 'Create decks', 'decks', 'create'), + ('decks.update', 'Update decks', 'decks', 'update'), + ('decks.delete', 'Delete decks', 'decks', 'delete'), + ('users.read', 'View users', 'users', 'read'), + ('users.create', 'Create users', 'users', 'create'), + ('users.update', 'Update users', 'users', 'update'), + ('users.delete', 'Delete users', 'users', 'delete'), + ('admin.access', 'Access admin panel', 'admin', 'access') +ON CONFLICT (name) DO NOTHING; + +-- Assign permissions to roles +-- User role permissions +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r, permissions p +WHERE r.name = 'user' +AND p.name IN ( + 'cards.read', + 'collections.read', 'collections.create', 'collections.update', 'collections.delete', + 'decks.read', 'decks.create', 'decks.update', 'decks.delete' +) +ON CONFLICT (role_id, permission_id) DO NOTHING; + +-- Admin role permissions (all permissions) +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r, permissions p +WHERE r.name = 'admin' +ON CONFLICT (role_id, permission_id) DO NOTHING; + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at); + +-- Create updated_at trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Add updated_at triggers +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 30bdc37..4a0726e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,45 +1,160 @@ import React from 'react'; -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/react'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; import Navbar from './components/Navbar'; import Dashboard from './pages/Dashboard'; import Collections from './pages/Collections'; import Decks from './pages/Decks'; import Cards from './pages/Cards'; import Scanner from './pages/Scanner'; -import Login from './pages/Login'; -import { AuthProvider } from './contexts/AuthContext'; -import './App.css'; +import LoginForm from './components/auth/LoginForm'; +import RegisterForm from './components/auth/RegisterForm'; +import AdminPanel from './components/admin/AdminPanel'; const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 60 * 1000, // 5 minutes + retry: 1, refetchOnWindowFocus: false, }, }, }); +// Protected Route Component +const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user, isLoading } = useAuth(); + + if (isLoading) { + return ( +