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:
parent
9243f6ff4e
commit
dc95d0d76d
23 changed files with 2515 additions and 174 deletions
219
api/admin/users.ts
Normal file
219
api/admin/users.ts
Normal 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
150
api/auth/login.ts
Normal 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
158
api/auth/register.ts
Normal 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
207
api/setup-auth.ts
Normal 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
128
package-lock.json
generated
|
|
@ -14,7 +14,9 @@
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/jest": "^27.5.2",
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^16.18.126",
|
"@types/node": "^16.18.126",
|
||||||
"@types/pg": "^8.15.4",
|
"@types/pg": "^8.15.4",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
|
|
@ -24,6 +26,8 @@
|
||||||
"@vercel/blob": "^1.1.1",
|
"@vercel/blob": "^1.1.1",
|
||||||
"@vercel/speed-insights": "^1.2.0",
|
"@vercel/speed-insights": "^1.2.0",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
|
@ -39,7 +43,7 @@
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20.x"
|
"node": "18.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
|
|
@ -6442,6 +6446,12 @@
|
||||||
"@babel/types": "^7.20.7"
|
"@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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
|
@ -6624,12 +6634,28 @@
|
||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "16.18.126",
|
"version": "16.18.126",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
|
||||||
|
|
@ -8451,6 +8477,12 @@
|
||||||
"node-int64": "^0.4.0"
|
"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": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
|
@ -10057,6 +10089,15 @@
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
|
@ -14436,6 +14477,28 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
|
|
@ -14451,6 +14514,27 @@
|
||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|
@ -14599,6 +14683,42 @@
|
||||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
|
|
@ -14611,6 +14731,12 @@
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.sortby": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/jest": "^27.5.2",
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^16.18.126",
|
"@types/node": "^16.18.126",
|
||||||
"@types/pg": "^8.15.4",
|
"@types/pg": "^8.15.4",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
|
|
@ -22,6 +24,8 @@
|
||||||
"@vercel/blob": "^1.1.1",
|
"@vercel/blob": "^1.1.1",
|
||||||
"@vercel/speed-insights": "^1.2.0",
|
"@vercel/speed-insights": "^1.2.0",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
|
|
||||||
138
scripts/user-auth-schema.sql
Normal file
138
scripts/user-auth-schema.sql
Normal 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();
|
||||||
151
src/App.tsx
151
src/App.tsx
|
|
@ -1,45 +1,160 @@
|
||||||
import React from 'react';
|
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Analytics } from '@vercel/analytics/react';
|
import { Analytics } from '@vercel/analytics/react';
|
||||||
import { SpeedInsights } from '@vercel/speed-insights/react';
|
import { SpeedInsights } from '@vercel/speed-insights/react';
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Collections from './pages/Collections';
|
import Collections from './pages/Collections';
|
||||||
import Decks from './pages/Decks';
|
import Decks from './pages/Decks';
|
||||||
import Cards from './pages/Cards';
|
import Cards from './pages/Cards';
|
||||||
import Scanner from './pages/Scanner';
|
import Scanner from './pages/Scanner';
|
||||||
import Login from './pages/Login';
|
import LoginForm from './components/auth/LoginForm';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import RegisterForm from './components/auth/RegisterForm';
|
||||||
import './App.css';
|
import AdminPanel from './components/admin/AdminPanel';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<div className="min-h-screen bg-gray-50">
|
<Routes>
|
||||||
<Navbar />
|
{/* Public Routes */}
|
||||||
<main className="container mx-auto px-4 py-8">
|
<Route path="/login" element={
|
||||||
<Routes>
|
<PublicRoute>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<LoginForm />
|
||||||
<Route path="/login" element={<Login />} />
|
</PublicRoute>
|
||||||
<Route path="/collections" element={<Collections />} />
|
} />
|
||||||
<Route path="/decks" element={<Decks />} />
|
<Route path="/register" element={
|
||||||
<Route path="/cards" element={<Cards />} />
|
<PublicRoute>
|
||||||
<Route path="/scanner" element={<Scanner />} />
|
<RegisterForm />
|
||||||
</Routes>
|
</PublicRoute>
|
||||||
</main>
|
} />
|
||||||
</div>
|
|
||||||
|
{/* 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 />
|
<Analytics />
|
||||||
<SpeedInsights />
|
<SpeedInsights />
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ const CameraScanner: React.FC<CameraScannerProps> = ({ onCardScanned, onError })
|
||||||
{!isStreaming ? (
|
{!isStreaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={startCamera}
|
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
|
<span>📹</span> Start Camera
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,131 +1,202 @@
|
||||||
import React, { useState } from 'react';
|
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';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
const Navbar: React.FC = () => {
|
const Navbar: React.FC = () => {
|
||||||
const { isAuthenticated, user, logout } = useAuth();
|
const { user, logout, isAdmin } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const navigate = useNavigate();
|
||||||
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
|
||||||
const navItems = [
|
const handleLogout = () => {
|
||||||
{ name: 'Dashboard', path: '/', icon: '📊' },
|
logout();
|
||||||
{ name: 'Collections', path: '/collections', icon: '📚' },
|
setIsUserMenuOpen(false);
|
||||||
{ name: 'Decks', path: '/decks', icon: '🎴' },
|
navigate('/login');
|
||||||
{ name: 'Cards', path: '/cards', icon: '🃏' },
|
};
|
||||||
{ name: 'Scanner', path: '/scanner', icon: '📸' },
|
|
||||||
|
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) => {
|
const isActive = (path: string) => location.pathname === path;
|
||||||
return location.pathname === path;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-white shadow-lg border-b border-gray-200">
|
<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">
|
<div className="flex justify-between items-center h-16">
|
||||||
{/* Logo and Brand */}
|
{/* Logo and Brand */}
|
||||||
<Link to="/" className="flex items-center space-x-2">
|
<div className="flex items-center">
|
||||||
<span className="text-2xl">🃏</span>
|
<Link to="/dashboard" className="flex items-center space-x-2">
|
||||||
<span className="text-xl font-bold text-gray-800">TCG Vault</span>
|
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white p-2 rounded-lg">
|
||||||
</Link>
|
<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 */}
|
{/* Navigation Links */}
|
||||||
{isAuthenticated && (
|
<div className="hidden md:flex items-center space-x-1">
|
||||||
<div className="hidden md:flex items-center space-x-8">
|
{navigation.map((item) => (
|
||||||
{navItems.map((item) => (
|
<Link
|
||||||
<Link
|
key={item.name}
|
||||||
key={item.name}
|
to={item.href}
|
||||||
to={item.path}
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200 flex items-center space-x-2 ${
|
||||||
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
isActive(item.href)
|
||||||
isActivePath(item.path)
|
? 'bg-indigo-100 text-indigo-700'
|
||||||
? 'text-primary-600 bg-primary-50'
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<span>{item.icon}</span>
|
||||||
<span>{item.icon}</span>
|
<span>{item.name}</span>
|
||||||
<span>{item.name}</span>
|
</Link>
|
||||||
</Link>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{isAuthenticated ? (
|
{/* Admin Badge */}
|
||||||
<div className="flex items-center space-x-4">
|
{isAdmin() && (
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/admin"
|
||||||
className="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile menu button */}
|
{/* User Dropdown */}
|
||||||
{isAuthenticated && (
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100"
|
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
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
|
<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">
|
||||||
className="h-6 w-6"
|
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
|
||||||
fill="none"
|
</div>
|
||||||
viewBox="0 0 24 24"
|
<div className="hidden md:block text-left">
|
||||||
stroke="currentColor"
|
<div className="text-sm font-medium text-gray-900">
|
||||||
>
|
{user?.firstName && user?.lastName
|
||||||
<path
|
? `${user.firstName} ${user.lastName}`
|
||||||
strokeLinecap="round"
|
: user?.username}
|
||||||
strokeLinejoin="round"
|
</div>
|
||||||
strokeWidth={2}
|
<div className="text-xs text-gray-500">{user?.email}</div>
|
||||||
d={
|
</div>
|
||||||
isMenuOpen
|
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
? 'M6 18L18 6M6 6l12 12'
|
<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" />
|
||||||
: 'M4 6h16M4 12h16M4 18h16'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Navigation */}
|
{/* Mobile Navigation */}
|
||||||
{isAuthenticated && isMenuOpen && (
|
<div className="md:hidden border-t border-gray-200">
|
||||||
<div className="md:hidden border-t border-gray-200 py-4">
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
<div className="flex flex-col space-y-2">
|
{navigation.map((item) => (
|
||||||
{navItems.map((item) => (
|
<Link
|
||||||
<Link
|
key={item.name}
|
||||||
key={item.name}
|
to={item.href}
|
||||||
to={item.path}
|
className={`block px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||||||
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
isActive(item.href)
|
||||||
isActivePath(item.path)
|
? 'bg-indigo-100 text-indigo-700'
|
||||||
? 'text-primary-600 bg-primary-50'
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
}`}
|
||||||
}`}
|
>
|
||||||
onClick={() => setIsMenuOpen(false)}
|
{item.icon} {item.name}
|
||||||
>
|
</Link>
|
||||||
<span>{item.icon}</span>
|
))}
|
||||||
<span>{item.name}</span>
|
{isAdmin() && (
|
||||||
</Link>
|
<Link
|
||||||
))}
|
to="/admin"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Click outside to close menu */}
|
||||||
|
{isUserMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
125
src/components/admin/AdminPanel.tsx
Normal file
125
src/components/admin/AdminPanel.tsx
Normal 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;
|
||||||
41
src/components/admin/CardManagement.tsx
Normal file
41
src/components/admin/CardManagement.tsx
Normal 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;
|
||||||
182
src/components/admin/SystemStats.tsx
Normal file
182
src/components/admin/SystemStats.tsx
Normal 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;
|
||||||
329
src/components/admin/UserManagement.tsx
Normal file
329
src/components/admin/UserManagement.tsx
Normal 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;
|
||||||
145
src/components/auth/LoginForm.tsx
Normal file
145
src/components/auth/LoginForm.tsx
Normal 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;
|
||||||
264
src/components/auth/RegisterForm.tsx
Normal file
264
src/components/auth/RegisterForm.tsx
Normal 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;
|
||||||
|
|
@ -2,17 +2,33 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
|
||||||
username: string;
|
username: string;
|
||||||
full_name?: string;
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions: string[];
|
||||||
|
lastLogin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | 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;
|
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);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
@ -32,38 +48,110 @@ interface AuthProviderProps {
|
||||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load user from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for stored token on app load
|
const savedToken = localStorage.getItem('tcg_vault_token');
|
||||||
const storedToken = localStorage.getItem('tcg_vault_token');
|
const savedUser = localStorage.getItem('tcg_vault_user');
|
||||||
const storedUser = localStorage.getItem('tcg_vault_user');
|
|
||||||
|
|
||||||
if (storedToken && storedUser) {
|
if (savedToken && savedUser) {
|
||||||
setToken(storedToken);
|
try {
|
||||||
setUser(JSON.parse(storedUser));
|
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) => {
|
const login = async (username: string, password: string) => {
|
||||||
setToken(newToken);
|
try {
|
||||||
setUser(newUser);
|
const response = await fetch('/api/auth/login', {
|
||||||
localStorage.setItem('tcg_vault_token', newToken);
|
method: 'POST',
|
||||||
localStorage.setItem('tcg_vault_user', JSON.stringify(newUser));
|
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 = () => {
|
const logout = () => {
|
||||||
setToken(null);
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
localStorage.removeItem('tcg_vault_token');
|
localStorage.removeItem('tcg_vault_token');
|
||||||
localStorage.removeItem('tcg_vault_user');
|
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,
|
user,
|
||||||
token,
|
token,
|
||||||
login,
|
login,
|
||||||
|
register,
|
||||||
logout,
|
logout,
|
||||||
isAuthenticated: !!token,
|
isLoading,
|
||||||
|
hasRole,
|
||||||
|
hasPermission,
|
||||||
|
isAdmin,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ const Cards: React.FC = () => {
|
||||||
📊 Table
|
📊 Table
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
Add Card
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,14 +137,14 @@ const Cards: React.FC = () => {
|
||||||
placeholder="Search cards..."
|
placeholder="Search cards..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={selectedGame}
|
value={selectedGame}
|
||||||
onChange={(e) => setSelectedGame(e.target.value)}
|
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="">All Games</option>
|
||||||
<option value="MTG">Magic: The Gathering</option>
|
<option value="MTG">Magic: The Gathering</option>
|
||||||
|
|
@ -154,7 +154,7 @@ const Cards: React.FC = () => {
|
||||||
<select
|
<select
|
||||||
value={selectedRarity}
|
value={selectedRarity}
|
||||||
onChange={(e) => setSelectedRarity(e.target.value)}
|
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="">All Rarities</option>
|
||||||
<option value="Common">Common</option>
|
<option value="Common">Common</option>
|
||||||
|
|
@ -171,7 +171,7 @@ const Cards: React.FC = () => {
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-white rounded-lg shadow border border-gray-200 p-8 text-center">
|
<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>
|
<p className="text-gray-600">Loading cards...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -196,7 +196,7 @@ const Cards: React.FC = () => {
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Try adjusting your search filters or add some cards to your collection
|
Try adjusting your search filters or add some cards to your collection
|
||||||
</p>
|
</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
|
Scan Cards
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-gray-100 hover:bg-gray-200 text-gray-800 px-6 py-3 rounded-md font-medium transition-colors">
|
<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)}` : '—'}
|
{card.current_price ? `$${card.current_price.toFixed(2)}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<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
|
View
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const Collections: React.FC = () => {
|
||||||
Organize and manage your trading card collections
|
Organize and manage your trading card collections
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
New Collection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -24,7 +24,7 @@ const Collections: React.FC = () => {
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Create your first collection to start organizing your cards
|
Create your first collection to start organizing your cards
|
||||||
</p>
|
</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
|
Create Collection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,7 @@ import { useAuth } from '../contexts/AuthContext';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const Decks: React.FC = () => {
|
||||||
Build and optimize your decks with AI assistance
|
Build and optimize your decks with AI assistance
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
New Deck
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -24,7 +24,7 @@ const Decks: React.FC = () => {
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Start building your first deck from your collection
|
Start building your first deck from your collection
|
||||||
</p>
|
</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
|
Build Deck
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ const Login: React.FC = () => {
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
required={!isLogin}
|
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"
|
placeholder="Email address"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
|
@ -106,7 +106,7 @@ const Login: React.FC = () => {
|
||||||
id="full_name"
|
id="full_name"
|
||||||
name="full_name"
|
name="full_name"
|
||||||
type="text"
|
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"
|
placeholder="Full name"
|
||||||
value={formData.full_name}
|
value={formData.full_name}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
|
@ -124,7 +124,7 @@ const Login: React.FC = () => {
|
||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
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"
|
placeholder="Username"
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
|
@ -140,7 +140,7 @@ const Login: React.FC = () => {
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
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"
|
placeholder="Password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
|
@ -158,7 +158,7 @@ const Login: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
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')}
|
{isLoading ? 'Please wait...' : (isLogin ? 'Sign in' : 'Sign up')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -168,7 +168,7 @@ const Login: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsLogin(!isLogin)}
|
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'}
|
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ const Scanner: React.FC = () => {
|
||||||
Drag and drop your card images here, or click to select files
|
Drag and drop your card images here, or click to select files
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-4">
|
<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
|
Choose Files
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-gray-100 hover:bg-gray-200 text-gray-800 px-6 py-3 rounded-md font-medium transition-colors">
|
<button className="bg-gray-100 hover:bg-gray-200 text-gray-800 px-6 py-3 rounded-md font-medium transition-colors">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue