From dc95d0d76dd8a367e8fd1fac17e2325ebaa7db2e Mon Sep 17 00:00:00 2001
From: Randall Stillwell
Date: Mon, 21 Jul 2025 21:06:38 -0500
Subject: [PATCH] Implement complete user authentication and admin panel system
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
✨ Features Added:
- User registration and login with JWT authentication
- Role-based access control (user/admin)
- Comprehensive admin panel with user management
- Password hashing with bcrypt
- Session management and token storage
- Protected routes and public routes
- Modern login/register forms with validation
🗄️ Database Schema:
- Users table with profile information
- Roles and permissions system
- User-role junction tables
- Session management tables
- Database triggers and indexes
🎨 UI/UX Improvements:
- Updated navigation with user menu
- Admin badge and access controls
- Responsive authentication forms
- Loading states and error handling
- Role-based UI elements
🔧 API Endpoints:
- /api/auth/login - User authentication
- /api/auth/register - User registration
- /api/admin/users - User management (admin only)
- /api/setup-auth - Database schema setup
🚀 Admin Panel Features:
- User listing with search and pagination
- Role assignment (user/admin)
- User activation/deactivation
- System dashboard with stats
- Card management placeholder
- Real-time user management
---
api/admin/users.ts | 219 ++++++++++++++++
api/auth/login.ts | 150 +++++++++++
api/auth/register.ts | 158 ++++++++++++
api/setup-auth.ts | 207 +++++++++++++++
package-lock.json | 128 ++++++++-
package.json | 4 +
scripts/user-auth-schema.sql | 138 ++++++++++
src/App.tsx | 151 +++++++++--
src/components/CameraScanner.tsx | 2 +-
src/components/Navbar.tsx | 263 ++++++++++++-------
src/components/admin/AdminPanel.tsx | 125 +++++++++
src/components/admin/CardManagement.tsx | 41 +++
src/components/admin/SystemStats.tsx | 182 +++++++++++++
src/components/admin/UserManagement.tsx | 329 ++++++++++++++++++++++++
src/components/auth/LoginForm.tsx | 145 +++++++++++
src/components/auth/RegisterForm.tsx | 264 +++++++++++++++++++
src/contexts/AuthContext.tsx | 124 +++++++--
src/pages/Cards.tsx | 14 +-
src/pages/Collections.tsx | 4 +-
src/pages/Dashboard.tsx | 23 +-
src/pages/Decks.tsx | 4 +-
src/pages/Login.tsx | 12 +-
src/pages/Scanner.tsx | 2 +-
23 files changed, 2515 insertions(+), 174 deletions(-)
create mode 100644 api/admin/users.ts
create mode 100644 api/auth/login.ts
create mode 100644 api/auth/register.ts
create mode 100644 api/setup-auth.ts
create mode 100644 scripts/user-auth-schema.sql
create mode 100644 src/components/admin/AdminPanel.tsx
create mode 100644 src/components/admin/CardManagement.tsx
create mode 100644 src/components/admin/SystemStats.tsx
create mode 100644 src/components/admin/UserManagement.tsx
create mode 100644 src/components/auth/LoginForm.tsx
create mode 100644 src/components/auth/RegisterForm.tsx
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 (
+
+ );
+ }
+
+ if (!user) {
+ return ;
+ }
+
+ return <>{children}>;
+};
+
+// Public Route Component (redirect if authenticated)
+const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const { user, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (user) {
+ return ;
+ }
+
+ return <>{children}>;
+};
+
+// Layout Component
+const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ return (
+
+
+
+ {children}
+
+
+ );
+};
+
function App() {
return (
-
-
-
-
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
+
+ {/* Public Routes */}
+
+
+
+ } />
+
+
+
+ } />
+
+ {/* Protected Routes */}
+
+
+
+
+
+ } />
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+ } />
+
+ {/* Admin Routes */}
+
+
+
+ } />
+
+ {/* Default redirect */}
+ } />
+
+ {/* 404 fallback */}
+
+
+
+ } />
+
diff --git a/src/components/CameraScanner.tsx b/src/components/CameraScanner.tsx
index 512239b..f626ca0 100644
--- a/src/components/CameraScanner.tsx
+++ b/src/components/CameraScanner.tsx
@@ -189,7 +189,7 @@ const CameraScanner: React.FC = ({ onCardScanned, onError })
{!isStreaming ? (
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index 023a864..633c0be 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -1,131 +1,202 @@
import React, { useState } from 'react';
-import { Link, useLocation } from 'react-router-dom';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Navbar: React.FC = () => {
- const { isAuthenticated, user, logout } = useAuth();
+ const { user, logout, isAdmin } = useAuth();
const location = useLocation();
- const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const navigate = useNavigate();
+ const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
- const navItems = [
- { name: 'Dashboard', path: '/', icon: '📊' },
- { name: 'Collections', path: '/collections', icon: '📚' },
- { name: 'Decks', path: '/decks', icon: '🎴' },
- { name: 'Cards', path: '/cards', icon: '🃏' },
- { name: 'Scanner', path: '/scanner', icon: '📸' },
+ const handleLogout = () => {
+ logout();
+ setIsUserMenuOpen(false);
+ navigate('/login');
+ };
+
+ const navigation = [
+ { name: 'Dashboard', href: '/dashboard', icon: '📊' },
+ { name: 'Cards', href: '/cards', icon: '🃏' },
+ { name: 'Collections', href: '/collections', icon: '📚' },
+ { name: 'Decks', href: '/decks', icon: '🎯' },
+ { name: 'Scanner', href: '/scanner', icon: '📷' },
];
- const isActivePath = (path: string) => {
- return location.pathname === path;
- };
+ const isActive = (path: string) => location.pathname === path;
return (
-
+
Choose Files