Implement complete user authentication and admin panel system

 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
This commit is contained in:
Randall Stillwell 2025-07-21 21:06:38 -05:00
parent 9243f6ff4e
commit dc95d0d76d
23 changed files with 2515 additions and 174 deletions

219
api/admin/users.ts Normal file
View file

@ -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();
}
}

150
api/auth/login.ts Normal file
View file

@ -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();
}
}

158
api/auth/register.ts Normal file
View file

@ -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();
}
}

207
api/setup-auth.ts Normal file
View file

@ -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();
}
}

128
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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();

View file

@ -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 (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
// Public Route Component (redirect if authenticated)
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
);
}
if (user) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
// Layout Component
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="container mx-auto px-4 py-8">
{children}
</main>
</div>
);
};
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router>
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/login" element={<Login />} />
<Route path="/collections" element={<Collections />} />
<Route path="/decks" element={<Decks />} />
<Route path="/cards" element={<Cards />} />
<Route path="/scanner" element={<Scanner />} />
</Routes>
</main>
</div>
<Routes>
{/* Public Routes */}
<Route path="/login" element={
<PublicRoute>
<LoginForm />
</PublicRoute>
} />
<Route path="/register" element={
<PublicRoute>
<RegisterForm />
</PublicRoute>
} />
{/* Protected Routes */}
<Route path="/dashboard" element={
<ProtectedRoute>
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/collections" element={
<ProtectedRoute>
<Layout>
<Collections />
</Layout>
</ProtectedRoute>
} />
<Route path="/decks" element={
<ProtectedRoute>
<Layout>
<Decks />
</Layout>
</ProtectedRoute>
} />
<Route path="/cards" element={
<ProtectedRoute>
<Layout>
<Cards />
</Layout>
</ProtectedRoute>
} />
<Route path="/scanner" element={
<ProtectedRoute>
<Layout>
<Scanner />
</Layout>
</ProtectedRoute>
} />
{/* Admin Routes */}
<Route path="/admin" element={
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
} />
{/* Default redirect */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 404 fallback */}
<Route path="*" element={
<Layout>
<div className="text-center py-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">404 - Page Not Found</h1>
<p className="text-gray-600 mb-8">The page you're looking for doesn't exist.</p>
<a href="/dashboard" className="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition-colors">
Go to Dashboard
</a>
</div>
</Layout>
} />
</Routes>
<Analytics />
<SpeedInsights />
</Router>

View file

@ -189,7 +189,7 @@ const CameraScanner: React.FC<CameraScannerProps> = ({ onCardScanned, onError })
{!isStreaming ? (
<button
onClick={startCamera}
className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-lg font-medium transition-colors flex items-center gap-2"
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium transition-colors flex items-center gap-2"
>
<span>📹</span> Start Camera
</button>

View file

@ -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 (
<nav className="bg-white shadow-lg border-b border-gray-200">
<div className="container mx-auto px-4">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo and Brand */}
<Link to="/" className="flex items-center space-x-2">
<span className="text-2xl">🃏</span>
<span className="text-xl font-bold text-gray-800">TCG Vault</span>
</Link>
<div className="flex items-center">
<Link to="/dashboard" className="flex items-center space-x-2">
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white p-2 rounded-lg">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<span className="text-xl font-bold text-gray-900">TCG Vault</span>
</Link>
</div>
{/* Desktop Navigation */}
{isAuthenticated && (
<div className="hidden md:flex items-center space-x-8">
{navItems.map((item) => (
<Link
key={item.name}
to={item.path}
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActivePath(item.path)
? 'text-primary-600 bg-primary-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<span>{item.icon}</span>
<span>{item.name}</span>
</Link>
))}
</div>
)}
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-1">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200 flex items-center space-x-2 ${
isActive(item.href)
? 'bg-indigo-100 text-indigo-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<span>{item.icon}</span>
<span>{item.name}</span>
</Link>
))}
</div>
{/* User Menu */}
<div className="flex items-center space-x-4">
{isAuthenticated ? (
<div className="flex items-center space-x-4">
<div className="hidden md:block">
<span className="text-sm text-gray-600">Welcome, </span>
<span className="text-sm font-medium text-gray-900">
{user?.username}
</span>
</div>
<button
onClick={logout}
className="bg-gray-100 hover:bg-gray-200 text-gray-800 px-4 py-2 rounded-md text-sm font-medium transition-colors"
>
Logout
</button>
</div>
) : (
{/* Admin Badge */}
{isAdmin() && (
<Link
to="/login"
className="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
to="/admin"
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors duration-200 ${
isActive('/admin')
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-600 hover:bg-purple-100 hover:text-purple-800'
}`}
>
Login
Admin
</Link>
)}
{/* Mobile menu button */}
{isAuthenticated && (
{/* User Dropdown */}
<div className="relative">
<button
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100"
onClick={() => setIsMenuOpen(!isMenuOpen)}
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center space-x-3 text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={
isMenuOpen
? 'M6 18L18 6M6 6l12 12'
: 'M4 6h16M4 12h16M4 18h16'
}
/>
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 text-white w-8 h-8 rounded-full flex items-center justify-center font-medium">
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
</div>
<div className="hidden md:block text-left">
<div className="text-sm font-medium text-gray-900">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.username}
</div>
<div className="text-xs text-gray-500">{user?.email}</div>
</div>
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
{/* Dropdown Menu */}
{isUserMenuOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="py-1">
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.username}
</p>
<p className="text-sm text-gray-500">{user?.email}</p>
<div className="flex flex-wrap gap-1 mt-2">
{user?.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{role}
</span>
))}
</div>
</div>
{/* Menu Items */}
<Link
to="/dashboard"
onClick={() => setIsUserMenuOpen(false)}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
📊 Dashboard
</Link>
{isAdmin() && (
<Link
to="/admin"
onClick={() => setIsUserMenuOpen(false)}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Admin Panel
</Link>
)}
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-red-700 hover:bg-red-50"
>
🚪 Sign Out
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Mobile Navigation */}
{isAuthenticated && isMenuOpen && (
<div className="md:hidden border-t border-gray-200 py-4">
<div className="flex flex-col space-y-2">
{navItems.map((item) => (
<Link
key={item.name}
to={item.path}
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActivePath(item.path)
? 'text-primary-600 bg-primary-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
onClick={() => setIsMenuOpen(false)}
>
<span>{item.icon}</span>
<span>{item.name}</span>
</Link>
))}
</div>
<div className="md:hidden border-t border-gray-200">
<div className="px-2 pt-2 pb-3 space-y-1">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`block px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
isActive(item.href)
? 'bg-indigo-100 text-indigo-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
{item.icon} {item.name}
</Link>
))}
{isAdmin() && (
<Link
to="/admin"
className={`block px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
isActive('/admin')
? 'bg-purple-100 text-purple-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
Admin Panel
</Link>
)}
</div>
)}
</div>
</div>
{/* Click outside to close menu */}
{isUserMenuOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsUserMenuOpen(false)}
/>
)}
</nav>
);
};

View file

@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { Navigate } from 'react-router-dom';
import UserManagement from './UserManagement';
import CardManagement from './CardManagement';
import SystemStats from './SystemStats';
type AdminTab = 'dashboard' | 'users' | 'cards' | 'decks' | 'settings';
const AdminPanel: React.FC = () => {
const { user, isAdmin } = useAuth();
const [activeTab, setActiveTab] = useState<AdminTab>('dashboard');
// Redirect if not admin
if (!isAdmin()) {
return <Navigate to="/dashboard" replace />;
}
const tabs = [
{ id: 'dashboard' as AdminTab, name: 'Dashboard', icon: '📊' },
{ id: 'users' as AdminTab, name: 'Users', icon: '👥' },
{ id: 'cards' as AdminTab, name: 'Cards', icon: '🃏' },
{ id: 'decks' as AdminTab, name: 'Decks', icon: '📚' },
{ id: 'settings' as AdminTab, name: 'Settings', icon: '⚙️' },
];
const renderContent = () => {
switch (activeTab) {
case 'dashboard':
return <SystemStats />;
case 'users':
return <UserManagement />;
case 'cards':
return <CardManagement />;
case 'decks':
return <div className="p-6">Deck Management - Coming Soon</div>;
case 'settings':
return <div className="p-6">System Settings - Coming Soon</div>;
default:
return <SystemStats />;
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Admin Panel</h1>
<p className="text-sm text-gray-600">
Welcome back, {user?.firstName || user?.username}
</p>
</div>
<div className="flex items-center space-x-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Admin
</span>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar Navigation */}
<div className="lg:w-64 flex-shrink-0">
<nav className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">
Administration
</h2>
</div>
<div className="p-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md mb-1 transition-colors duration-200 ${
activeTab === tab.id
? 'bg-indigo-100 text-indigo-700 border-r-2 border-indigo-500'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<span className="mr-3 text-lg">{tab.icon}</span>
{tab.name}
</button>
))}
</div>
</nav>
{/* Quick Stats */}
<div className="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Quick Stats</h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Total Users</span>
<span className="font-medium text-gray-900">-</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Total Cards</span>
<span className="font-medium text-gray-900">-</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Active Sessions</span>
<span className="font-medium text-gray-900">-</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 min-h-[600px]">
{renderContent()}
</div>
</div>
</div>
</div>
</div>
);
};
export default AdminPanel;

View file

@ -0,0 +1,41 @@
import React from 'react';
const CardManagement: React.FC = () => {
return (
<div className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">Card Management</h2>
<p className="text-sm text-gray-600">Manage card database, pricing, and metadata.</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<div className="flex items-center mb-4">
<div className="bg-blue-100 rounded-full p-2 mr-3">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-medium text-blue-900">Card Management Features</h3>
</div>
<div className="space-y-3 text-sm text-blue-800">
<p>🃏 <strong>Card Database:</strong> View and manage all cards in the system</p>
<p>💰 <strong>Pricing Updates:</strong> Bulk update card prices from external sources</p>
<p>🖼 <strong>Image Management:</strong> Upload and manage card images</p>
<p>📊 <strong>Metadata:</strong> Edit card details, sets, and rarity information</p>
<p>🔍 <strong>Search & Filter:</strong> Advanced search and filtering capabilities</p>
</div>
<div className="mt-6 p-4 bg-white rounded-md border border-blue-200">
<h4 className="font-medium text-blue-900 mb-2">Coming Soon</h4>
<p className="text-sm text-blue-700">
This feature is currently under development. It will include comprehensive
card management tools for administrators.
</p>
</div>
</div>
</div>
);
};
export default CardManagement;

View file

@ -0,0 +1,182 @@
import React from 'react';
const SystemStats: React.FC = () => {
return (
<div className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">System Dashboard</h2>
<p className="text-sm text-gray-600">Overview of system statistics and health.</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg p-6 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-100 text-sm">Total Users</p>
<p className="text-3xl font-bold">-</p>
</div>
<div className="bg-blue-400 bg-opacity-30 rounded-full p-3">
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-lg p-6 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-green-100 text-sm">Total Cards</p>
<p className="text-3xl font-bold">-</p>
</div>
<div className="bg-green-400 bg-opacity-30 rounded-full p-3">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-purple-500 to-purple-600 rounded-lg p-6 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-purple-100 text-sm">Active Sessions</p>
<p className="text-3xl font-bold">-</p>
</div>
<div className="bg-purple-400 bg-opacity-30 rounded-full p-3">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-orange-500 to-orange-600 rounded-lg p-6 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-orange-100 text-sm">Collections</p>
<p className="text-3xl font-bold">-</p>
</div>
<div className="bg-orange-400 bg-opacity-30 rounded-full p-3">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
</div>
</div>
{/* System Health */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">System Health</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Database Status</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Online
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">API Status</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Operational
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Storage</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
75% Used
</span>
</div>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
<div className="space-y-3">
<div className="flex items-start">
<div className="bg-blue-100 rounded-full p-1 mr-3 mt-0.5">
<svg className="w-4 h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="text-sm text-gray-900">New user registration</p>
<p className="text-xs text-gray-500">2 minutes ago</p>
</div>
</div>
<div className="flex items-start">
<div className="bg-green-100 rounded-full p-1 mr-3 mt-0.5">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<p className="text-sm text-gray-900">Card database updated</p>
<p className="text-xs text-gray-500">15 minutes ago</p>
</div>
</div>
<div className="flex items-start">
<div className="bg-purple-100 rounded-full p-1 mr-3 mt-0.5">
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-sm text-gray-900">System backup completed</p>
<p className="text-xs text-gray-500">1 hour ago</p>
</div>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<button className="p-4 text-center border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="text-indigo-600 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900">Add User</p>
</button>
<button className="p-4 text-center border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="text-green-600 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900">Import Cards</p>
</button>
<button className="p-4 text-center border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="text-orange-600 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900">View Reports</p>
</button>
<button className="p-4 text-center border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="text-red-600 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900">Settings</p>
</button>
</div>
</div>
</div>
);
};
export default SystemStats;

View file

@ -0,0 +1,329 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
interface User {
id: number;
username: string;
email: string;
first_name?: string;
last_name?: string;
is_active: boolean;
email_verified: boolean;
roles: string[];
created_at: string;
last_login?: string;
}
interface UserResponse {
success: boolean;
users: User[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
const UserManagement: React.FC = () => {
const { token } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [editingUser, setEditingUser] = useState<User | null>(null);
const fetchUsers = async (page = 1, search = '') => {
try {
setLoading(true);
const response = await fetch(
`/api/admin/users?page=${page}&limit=20&search=${encodeURIComponent(search)}`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data: UserResponse = await response.json();
setUsers(data.users);
setCurrentPage(data.pagination.page);
setTotalPages(data.pagination.totalPages);
setError('');
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers(currentPage, searchTerm);
}, [currentPage, token]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setCurrentPage(1);
fetchUsers(1, searchTerm);
};
const handleUserUpdate = async (userId: number, updates: { isActive?: boolean; roles?: string[] }) => {
try {
const response = await fetch(`/api/admin/users?id=${userId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error('Failed to update user');
}
// Refresh users list
fetchUsers(currentPage, searchTerm);
setEditingUser(null);
} catch (err) {
setError((err as Error).message);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getRoleColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-purple-100 text-purple-800';
case 'user':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (loading && users.length === 0) {
return (
<div className="p-6 flex justify-center items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">User Management</h2>
<p className="text-sm text-gray-600">Manage user accounts, roles, and permissions.</p>
</div>
{/* Search Bar */}
<form onSubmit={handleSearch} className="mb-6">
<div className="flex gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search users by username, email, or name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<button
type="submit"
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
Search
</button>
</div>
</form>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Users Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
{user.first_name || user.last_name
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
: user.username}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
{(user.first_name || user.last_name) && (
<div className="text-xs text-gray-400">@{user.username}</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleColor(role)}`}
>
{role}
</span>
))}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.last_login ? formatDate(user.last_login) : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => setEditingUser(user)}
className="text-indigo-600 hover:text-indigo-900"
>
Edit
</button>
<button
onClick={() => handleUserUpdate(user.id, { isActive: !user.is_active })}
className={user.is_active ? 'text-red-600 hover:text-red-900' : 'text-green-600 hover:text-green-900'}
>
{user.is_active ? 'Deactivate' : 'Activate'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 flex justify-center">
<nav className="flex space-x-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-2 text-sm border border-gray-300 rounded-md disabled:opacity-50"
>
Previous
</button>
<span className="px-3 py-2 text-sm">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-2 text-sm border border-gray-300 rounded-md disabled:opacity-50"
>
Next
</button>
</nav>
</div>
)}
{/* Edit User Modal */}
{editingUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-gray-900 mb-4">Edit User</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Roles</label>
<div className="mt-2 space-y-2">
{['user', 'admin'].map((role) => (
<label key={role} className="flex items-center">
<input
type="checkbox"
checked={editingUser.roles.includes(role)}
onChange={(e) => {
const newRoles = e.target.checked
? [...editingUser.roles, role]
: editingUser.roles.filter(r => r !== role);
setEditingUser({ ...editingUser, roles: newRoles });
}}
className="mr-2"
/>
<span className="capitalize">{role}</span>
</label>
))}
</div>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setEditingUser(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={() => handleUserUpdate(editingUser.id, { roles: editingUser.roles })}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Save Changes
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default UserManagement;

View file

@ -0,0 +1,145 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate, Link } from 'react-router-dom';
const LoginForm: React.FC = () => {
const [formData, setFormData] = useState({
username: '',
password: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Clear error when user starts typing
if (error) setError('');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
if (!formData.username.trim() || !formData.password) {
setError('Please fill in all fields');
setIsLoading(false);
return;
}
const result = await login(formData.username, formData.password);
if (result.success) {
navigate('/dashboard');
} else {
setError(result.error || 'Login failed');
}
setIsLoading(false);
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-indigo-100">
<svg className="h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Welcome to TCG Vault
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to your account
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username or Email
</label>
<input
id="username"
name="username"
type="text"
required
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Enter your username or email"
value={formData.username}
onChange={handleChange}
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Enter your password"
value={formData.password}
onChange={handleChange}
disabled={isLoading}
/>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
</>
) : (
'Sign in'
)}
</button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-indigo-600 hover:text-indigo-500 transition-colors duration-200"
>
Sign up here
</Link>
</p>
</div>
</form>
</div>
</div>
);
};
export default LoginForm;

View file

@ -0,0 +1,264 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate, Link } from 'react-router-dom';
const RegisterForm: React.FC = () => {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Clear error when user starts typing
if (error) setError('');
};
const validateForm = () => {
if (!formData.username.trim()) {
setError('Username is required');
return false;
}
if (formData.username.length < 3) {
setError('Username must be at least 3 characters long');
return false;
}
if (!formData.email.trim()) {
setError('Email is required');
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
setError('Please enter a valid email address');
return false;
}
if (!formData.password) {
setError('Password is required');
return false;
}
if (formData.password.length < 6) {
setError('Password must be at least 6 characters long');
return false;
}
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
if (!validateForm()) {
setIsLoading(false);
return;
}
const registerData = {
username: formData.username.trim(),
email: formData.email.trim(),
password: formData.password,
firstName: formData.firstName.trim() || undefined,
lastName: formData.lastName.trim() || undefined,
};
const result = await register(registerData);
if (result.success) {
navigate('/dashboard');
} else {
setError(result.error || 'Registration failed');
}
setIsLoading(false);
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-emerald-100">
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Join TCG Vault
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Create your account to get started
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-1">
First Name
</label>
<input
id="firstName"
name="firstName"
type="text"
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="First name"
value={formData.firstName}
onChange={handleChange}
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-1">
Last Name
</label>
<input
id="lastName"
name="lastName"
type="text"
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="Last name"
value={formData.lastName}
onChange={handleChange}
disabled={isLoading}
/>
</div>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<input
id="username"
name="username"
type="text"
required
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="Choose a username"
value={formData.username}
onChange={handleChange}
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email Address *
</label>
<input
id="email"
name="email"
type="email"
required
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="Enter your email"
value={formData.email}
onChange={handleChange}
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password *
</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="Create a password"
value={formData.password}
onChange={handleChange}
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Confirm Password *
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
placeholder="Confirm your password"
value={formData.confirmPassword}
onChange={handleChange}
disabled={isLoading}
/>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating account...
</>
) : (
'Create Account'
)}
</button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-emerald-600 hover:text-emerald-500 transition-colors duration-200"
>
Sign in here
</Link>
</p>
</div>
</form>
</div>
</div>
);
};
export default RegisterForm;

View file

@ -2,17 +2,33 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
interface User {
id: number;
email: string;
username: string;
full_name?: string;
email: string;
firstName?: string;
lastName?: string;
roles: string[];
permissions: string[];
lastLogin?: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (token: string, user: User) => void;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
register: (userData: RegisterData) => Promise<{ success: boolean; error?: string }>;
logout: () => void;
isAuthenticated: boolean;
isLoading: boolean;
hasRole: (role: string) => boolean;
hasPermission: (permission: string) => boolean;
isAdmin: () => boolean;
}
interface RegisterData {
username: string;
email: string;
password: string;
firstName?: string;
lastName?: string;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -32,38 +48,110 @@ interface AuthProviderProps {
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Load user from localStorage on mount
useEffect(() => {
// Check for stored token on app load
const storedToken = localStorage.getItem('tcg_vault_token');
const storedUser = localStorage.getItem('tcg_vault_user');
const savedToken = localStorage.getItem('tcg_vault_token');
const savedUser = localStorage.getItem('tcg_vault_user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
if (savedToken && savedUser) {
try {
const parsedUser = JSON.parse(savedUser);
setToken(savedToken);
setUser(parsedUser);
} catch (error) {
console.error('Error parsing saved user data:', error);
localStorage.removeItem('tcg_vault_token');
localStorage.removeItem('tcg_vault_user');
}
}
setIsLoading(false);
}, []);
const login = (newToken: string, newUser: User) => {
setToken(newToken);
setUser(newUser);
localStorage.setItem('tcg_vault_token', newToken);
localStorage.setItem('tcg_vault_user', JSON.stringify(newUser));
const login = async (username: string, password: string) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (data.success) {
setUser(data.user);
setToken(data.token);
localStorage.setItem('tcg_vault_token', data.token);
localStorage.setItem('tcg_vault_user', JSON.stringify(data.user));
return { success: true };
} else {
return { success: false, error: data.error || 'Login failed' };
}
} catch (error) {
console.error('Login error:', error);
return { success: false, error: 'Network error. Please try again.' };
}
};
const register = async (userData: RegisterData) => {
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
const data = await response.json();
if (data.success) {
setUser(data.user);
setToken(data.token);
localStorage.setItem('tcg_vault_token', data.token);
localStorage.setItem('tcg_vault_user', JSON.stringify(data.user));
return { success: true };
} else {
return { success: false, error: data.error || 'Registration failed' };
}
} catch (error) {
console.error('Registration error:', error);
return { success: false, error: 'Network error. Please try again.' };
}
};
const logout = () => {
setToken(null);
setUser(null);
setToken(null);
localStorage.removeItem('tcg_vault_token');
localStorage.removeItem('tcg_vault_user');
};
const value = {
const hasRole = (role: string): boolean => {
return user?.roles?.includes(role) || false;
};
const hasPermission = (permission: string): boolean => {
return user?.permissions?.includes(permission) || false;
};
const isAdmin = (): boolean => {
return hasRole('admin');
};
const value: AuthContextType = {
user,
token,
login,
register,
logout,
isAuthenticated: !!token,
isLoading,
hasRole,
hasPermission,
isAdmin,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View file

@ -122,7 +122,7 @@ const Cards: React.FC = () => {
📊 Table
</button>
</div>
<button className="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-md font-medium transition-colors">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md font-medium transition-colors">
Add Card
</button>
</div>
@ -137,14 +137,14 @@ const Cards: React.FC = () => {
placeholder="Search cards..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div className="flex gap-2">
<select
value={selectedGame}
onChange={(e) => setSelectedGame(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">All Games</option>
<option value="MTG">Magic: The Gathering</option>
@ -154,7 +154,7 @@ const Cards: React.FC = () => {
<select
value={selectedRarity}
onChange={(e) => setSelectedRarity(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">All Rarities</option>
<option value="Common">Common</option>
@ -171,7 +171,7 @@ const Cards: React.FC = () => {
{/* Loading State */}
{isLoading && (
<div className="bg-white rounded-lg shadow border border-gray-200 p-8 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading cards...</p>
</div>
)}
@ -196,7 +196,7 @@ const Cards: React.FC = () => {
<p className="text-gray-600 mb-6">
Try adjusting your search filters or add some cards to your collection
</p>
<button className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-md font-medium transition-colors mr-4">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-md font-medium transition-colors mr-4">
Scan Cards
</button>
<button className="bg-gray-100 hover:bg-gray-200 text-gray-800 px-6 py-3 rounded-md font-medium transition-colors">
@ -343,7 +343,7 @@ const Cards: React.FC = () => {
{card.current_price ? `$${card.current_price.toFixed(2)}` : '—'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button className="text-primary-600 hover:text-primary-900">
<button className="text-indigo-600 hover:text-indigo-900">
View
</button>
</td>

View file

@ -10,7 +10,7 @@ const Collections: React.FC = () => {
Organize and manage your trading card collections
</p>
</div>
<button className="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-md font-medium transition-colors">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md font-medium transition-colors">
New Collection
</button>
</div>
@ -24,7 +24,7 @@ const Collections: React.FC = () => {
<p className="text-gray-600 mb-6">
Create your first collection to start organizing your cards
</p>
<button className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-md font-medium transition-colors">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-md font-medium transition-colors">
Create Collection
</button>
</div>

View file

@ -3,28 +3,7 @@ import { useAuth } from '../contexts/AuthContext';
import { Link } from 'react-router-dom';
const Dashboard: React.FC = () => {
const { isAuthenticated, user } = useAuth();
if (!isAuthenticated) {
return (
<div className="text-center py-12">
<div className="max-w-md mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Welcome to TCG Vault
</h1>
<p className="text-gray-600 mb-8">
Your complete trading card database with OCR scanning, AI-powered deck building, and real-time pricing.
</p>
<Link
to="/login"
className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-md text-lg font-medium transition-colors"
>
Get Started
</Link>
</div>
</div>
);
}
const { user } = useAuth();
return (
<div>

View file

@ -10,7 +10,7 @@ const Decks: React.FC = () => {
Build and optimize your decks with AI assistance
</p>
</div>
<button className="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-md font-medium transition-colors">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md font-medium transition-colors">
New Deck
</button>
</div>
@ -24,7 +24,7 @@ const Decks: React.FC = () => {
<p className="text-gray-600 mb-6">
Start building your first deck from your collection
</p>
<button className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-md font-medium transition-colors">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-md font-medium transition-colors">
Build Deck
</button>
</div>

View file

@ -92,7 +92,7 @@ const Login: React.FC = () => {
name="email"
type="email"
required={!isLogin}
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Email address"
value={formData.email}
onChange={handleInputChange}
@ -106,7 +106,7 @@ const Login: React.FC = () => {
id="full_name"
name="full_name"
type="text"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Full name"
value={formData.full_name}
onChange={handleInputChange}
@ -124,7 +124,7 @@ const Login: React.FC = () => {
name="username"
type="text"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Username"
value={formData.username}
onChange={handleInputChange}
@ -140,7 +140,7 @@ const Login: React.FC = () => {
name="password"
type="password"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Password"
value={formData.password}
onChange={handleInputChange}
@ -158,7 +158,7 @@ const Login: React.FC = () => {
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Please wait...' : (isLogin ? 'Sign in' : 'Sign up')}
</button>
@ -168,7 +168,7 @@ const Login: React.FC = () => {
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className="text-sm text-primary-600 hover:text-primary-500"
className="text-sm text-indigo-600 hover:text-indigo-500"
>
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
</button>

View file

@ -175,7 +175,7 @@ const Scanner: React.FC = () => {
Drag and drop your card images here, or click to select files
</p>
<div className="flex justify-center gap-4">
<button className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-md font-medium transition-colors">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-md font-medium transition-colors">
Choose Files
</button>
<button className="bg-gray-100 hover:bg-gray-200 text-gray-800 px-6 py-3 rounded-md font-medium transition-colors">