27 changed files with 1364 additions and 55 deletions
@ -0,0 +1,410 @@ |
|||||||
|
import { Response } from 'express'; |
||||||
|
import { AuthRequest } from '../types/index.js'; |
||||||
|
import { db } from '../config/database.js'; |
||||||
|
import { hashPassword, verifyPassword } from '../services/auth.service.js'; |
||||||
|
|
||||||
|
export async function getAllUsers(req: AuthRequest, res: Response) { |
||||||
|
try { |
||||||
|
const result = await db.execute({ |
||||||
|
sql: 'SELECT id, username, role, created_at, last_login FROM users ORDER BY created_at DESC', |
||||||
|
args: [] |
||||||
|
}); |
||||||
|
|
||||||
|
res.json({ |
||||||
|
success: true, |
||||||
|
data: result.rows.map(user => ({ |
||||||
|
id: user.id, |
||||||
|
username: user.username, |
||||||
|
role: user.role || 'user', |
||||||
|
createdAt: user.created_at, |
||||||
|
lastLogin: user.last_login |
||||||
|
})) |
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
console.error('Get all users error:', error); |
||||||
|
res.status(500).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'GET_USERS_ERROR', |
||||||
|
message: 'Error fetching users' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export async function createUser(req: AuthRequest, res: Response) { |
||||||
|
try { |
||||||
|
const { username, password, role } = req.body; |
||||||
|
|
||||||
|
if (!username || !password) { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'MISSING_FIELDS', |
||||||
|
message: 'Username and password are required' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate role
|
||||||
|
if (role && role !== 'admin' && role !== 'user') { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'INVALID_ROLE', |
||||||
|
message: 'Role must be "admin" or "user"' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (password.length < 8) { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'WEAK_PASSWORD', |
||||||
|
message: 'Password must be at least 8 characters long' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
const existing = await db.execute({ |
||||||
|
sql: 'SELECT id FROM users WHERE username = ?', |
||||||
|
args: [username] |
||||||
|
}); |
||||||
|
|
||||||
|
if (existing.rows.length > 0) { |
||||||
|
return res.status(409).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'USERNAME_EXISTS', |
||||||
|
message: 'Username already exists' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await hashPassword(password); |
||||||
|
|
||||||
|
// Insert user
|
||||||
|
const result = await db.execute({ |
||||||
|
sql: 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', |
||||||
|
args: [username, passwordHash, role || 'user'] |
||||||
|
}); |
||||||
|
|
||||||
|
// Get created user
|
||||||
|
const newUser = await db.execute({ |
||||||
|
sql: 'SELECT id, username, role, created_at FROM users WHERE id = ?', |
||||||
|
args: [result.lastInsertRowid] |
||||||
|
}); |
||||||
|
|
||||||
|
res.status(201).json({ |
||||||
|
success: true, |
||||||
|
data: { |
||||||
|
id: newUser.rows[0].id, |
||||||
|
username: newUser.rows[0].username, |
||||||
|
role: newUser.rows[0].role || 'user', |
||||||
|
createdAt: newUser.rows[0].created_at |
||||||
|
} |
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
console.error('Create user error:', error); |
||||||
|
res.status(500).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'CREATE_USER_ERROR', |
||||||
|
message: 'Error creating user' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateUser(req: AuthRequest, res: Response) { |
||||||
|
try { |
||||||
|
const userId = parseInt(req.params.id); |
||||||
|
const { username, role } = req.body; |
||||||
|
|
||||||
|
if (!userId || isNaN(userId)) { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'INVALID_USER_ID', |
||||||
|
message: 'Invalid user ID' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Prevent self-deletion check (for role changes)
|
||||||
|
if (userId === req.userId && role && role !== 'admin') { |
||||||
|
return res.status(403).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'CANNOT_DEMOTE_SELF', |
||||||
|
message: 'You cannot change your own role' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate role if provided
|
||||||
|
if (role && role !== 'admin' && role !== 'user') { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'INVALID_ROLE', |
||||||
|
message: 'Role must be "admin" or "user"' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const existing = await db.execute({ |
||||||
|
sql: 'SELECT id FROM users WHERE id = ?', |
||||||
|
args: [userId] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!existing.rows.length) { |
||||||
|
return res.status(404).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'USER_NOT_FOUND', |
||||||
|
message: 'User not found' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Check username uniqueness if changing username
|
||||||
|
if (username) { |
||||||
|
const usernameCheck = await db.execute({ |
||||||
|
sql: 'SELECT id FROM users WHERE username = ? AND id != ?', |
||||||
|
args: [username, userId] |
||||||
|
}); |
||||||
|
|
||||||
|
if (usernameCheck.rows.length > 0) { |
||||||
|
return res.status(409).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'USERNAME_EXISTS', |
||||||
|
message: 'Username already exists' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Build update query dynamically
|
||||||
|
const updates: string[] = []; |
||||||
|
const args: any[] = []; |
||||||
|
|
||||||
|
if (username) { |
||||||
|
updates.push('username = ?'); |
||||||
|
args.push(username); |
||||||
|
} |
||||||
|
|
||||||
|
if (role) { |
||||||
|
updates.push('role = ?'); |
||||||
|
args.push(role); |
||||||
|
} |
||||||
|
|
||||||
|
if (updates.length === 0) { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'NO_UPDATES', |
||||||
|
message: 'No fields to update' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
args.push(userId); |
||||||
|
|
||||||
|
await db.execute({ |
||||||
|
sql: `UPDATE users SET ${updates.join(', ')} WHERE id = ?`, |
||||||
|
args |
||||||
|
}); |
||||||
|
|
||||||
|
// Get updated user
|
||||||
|
const updated = await db.execute({ |
||||||
|
sql: 'SELECT id, username, role, created_at, last_login FROM users WHERE id = ?', |
||||||
|
args: [userId] |
||||||
|
}); |
||||||
|
|
||||||
|
res.json({ |
||||||
|
success: true, |
||||||
|
data: { |
||||||
|
id: updated.rows[0].id, |
||||||
|
username: updated.rows[0].username, |
||||||
|
role: updated.rows[0].role || 'user', |
||||||
|
createdAt: updated.rows[0].created_at, |
||||||
|
lastLogin: updated.rows[0].last_login |
||||||
|
} |
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
console.error('Update user error:', error); |
||||||
|
res.status(500).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'UPDATE_USER_ERROR', |
||||||
|
message: 'Error updating user' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export async function deleteUser(req: AuthRequest, res: Response) { |
||||||
|
try { |
||||||
|
const userId = parseInt(req.params.id); |
||||||
|
|
||||||
|
if (!userId || isNaN(userId)) { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'INVALID_USER_ID', |
||||||
|
message: 'Invalid user ID' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Prevent self-deletion
|
||||||
|
if (userId === req.userId) { |
||||||
|
return res.status(403).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'CANNOT_DELETE_SELF', |
||||||
|
message: 'You cannot delete your own account' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const existing = await db.execute({ |
||||||
|
sql: 'SELECT id FROM users WHERE id = ?', |
||||||
|
args: [userId] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!existing.rows.length) { |
||||||
|
return res.status(404).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'USER_NOT_FOUND', |
||||||
|
message: 'User not found' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Delete user (cascade will handle related data)
|
||||||
|
await db.execute({ |
||||||
|
sql: 'DELETE FROM users WHERE id = ?', |
||||||
|
args: [userId] |
||||||
|
}); |
||||||
|
|
||||||
|
res.json({ |
||||||
|
success: true, |
||||||
|
data: { message: 'User deleted successfully' } |
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
console.error('Delete user error:', error); |
||||||
|
res.status(500).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'DELETE_USER_ERROR', |
||||||
|
message: 'Error deleting user' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export async function changePassword(req: AuthRequest, res: Response) { |
||||||
|
try { |
||||||
|
const userId = parseInt(req.params.id); |
||||||
|
const { password } = req.body; |
||||||
|
|
||||||
|
if (!userId || isNaN(userId)) { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'INVALID_USER_ID', |
||||||
|
message: 'Invalid user ID' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
if (!password) { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'MISSING_PASSWORD', |
||||||
|
message: 'Password is required' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (password.length < 8) { |
||||||
|
return res.status(400).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'WEAK_PASSWORD', |
||||||
|
message: 'Password must be at least 8 characters long' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const existing = await db.execute({ |
||||||
|
sql: 'SELECT id FROM users WHERE id = ?', |
||||||
|
args: [userId] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!existing.rows.length) { |
||||||
|
return res.status(404).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'USER_NOT_FOUND', |
||||||
|
message: 'User not found' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Allow admin to change any password, or user to change their own
|
||||||
|
if (userId !== req.userId) { |
||||||
|
// Check if requester is admin (this should already be checked by middleware, but double-check)
|
||||||
|
const requester = await db.execute({ |
||||||
|
sql: 'SELECT role FROM users WHERE id = ?', |
||||||
|
args: [req.userId] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!requester.rows.length || requester.rows[0].role !== 'admin') { |
||||||
|
return res.status(403).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'FORBIDDEN', |
||||||
|
message: 'You can only change your own password' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const passwordHash = await hashPassword(password); |
||||||
|
|
||||||
|
// Update password
|
||||||
|
await db.execute({ |
||||||
|
sql: 'UPDATE users SET password_hash = ? WHERE id = ?', |
||||||
|
args: [passwordHash, userId] |
||||||
|
}); |
||||||
|
|
||||||
|
res.json({ |
||||||
|
success: true, |
||||||
|
data: { message: 'Password changed successfully' } |
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
console.error('Change password error:', error); |
||||||
|
res.status(500).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'CHANGE_PASSWORD_ERROR', |
||||||
|
message: 'Error changing password' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
import { Response, NextFunction } from 'express'; |
||||||
|
import { AuthRequest } from '../types/index.js'; |
||||||
|
import { db } from '../config/database.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Admin middleware - ensures user is authenticated AND has admin role |
||||||
|
* Must be used after authMiddleware |
||||||
|
*/ |
||||||
|
export async function adminMiddleware( |
||||||
|
req: AuthRequest, |
||||||
|
res: Response, |
||||||
|
next: NextFunction |
||||||
|
) { |
||||||
|
if (!req.userId) { |
||||||
|
return res.status(401).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'UNAUTHORIZED', |
||||||
|
message: 'Authentication required' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Get user role from database
|
||||||
|
const result = await db.execute({ |
||||||
|
sql: 'SELECT role FROM users WHERE id = ?', |
||||||
|
args: [req.userId] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!result.rows.length) { |
||||||
|
return res.status(404).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'USER_NOT_FOUND', |
||||||
|
message: 'User not found' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const user = result.rows[0]; |
||||||
|
const role = user.role as string; |
||||||
|
|
||||||
|
if (role !== 'admin') { |
||||||
|
return res.status(403).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'FORBIDDEN', |
||||||
|
message: 'Admin access required' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// User is admin, proceed
|
||||||
|
next(); |
||||||
|
} catch (error) { |
||||||
|
console.error('Admin middleware error:', error); |
||||||
|
return res.status(500).json({ |
||||||
|
success: false, |
||||||
|
error: { |
||||||
|
code: 'ADMIN_CHECK_ERROR', |
||||||
|
message: 'Error checking admin status' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
import { Router } from 'express'; |
||||||
|
import { |
||||||
|
getAllUsers, |
||||||
|
createUser, |
||||||
|
updateUser, |
||||||
|
deleteUser, |
||||||
|
changePassword |
||||||
|
} from '../controllers/users.controller.js'; |
||||||
|
import { authMiddleware } from '../middleware/auth.js'; |
||||||
|
import { adminMiddleware } from '../middleware/admin.js'; |
||||||
|
|
||||||
|
const router = Router(); |
||||||
|
|
||||||
|
// All user management routes require admin access
|
||||||
|
router.get('/', authMiddleware, adminMiddleware, getAllUsers); |
||||||
|
router.post('/', authMiddleware, adminMiddleware, createUser); |
||||||
|
router.put('/:id', authMiddleware, adminMiddleware, updateUser); |
||||||
|
router.delete('/:id', authMiddleware, adminMiddleware, deleteUser); |
||||||
|
router.put('/:id/password', authMiddleware, changePassword); // Admin or self
|
||||||
|
|
||||||
|
export default router; |
||||||
|
After Width: | Height: | Size: 14 KiB |
@ -0,0 +1,24 @@ |
|||||||
|
{ |
||||||
|
"name": "Rainbow, Cupcakes & Unicorns - Free Games for Kids", |
||||||
|
"short_name": "Kiddos Games", |
||||||
|
"description": "Free educational games for kids! Play videos, speech sounds, and tic-tac-toe games.", |
||||||
|
"start_url": "/", |
||||||
|
"display": "standalone", |
||||||
|
"background_color": "#ffffff", |
||||||
|
"theme_color": "#ec4899", |
||||||
|
"orientation": "portrait-primary", |
||||||
|
"icons": [ |
||||||
|
{ |
||||||
|
"src": "/rainbow.png", |
||||||
|
"sizes": "192x192", |
||||||
|
"type": "image/png", |
||||||
|
"purpose": "any maskable" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"src": "/cupcake.png", |
||||||
|
"sizes": "512x512", |
||||||
|
"type": "image/png", |
||||||
|
"purpose": "any maskable" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@ -0,0 +1,125 @@ |
|||||||
|
interface OptimizedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { |
||||||
|
src: string; |
||||||
|
alt: string; |
||||||
|
// For external images (like YouTube thumbnails) - applies additional optimizations
|
||||||
|
isExternal?: boolean; |
||||||
|
// Disable WebP if needed (WebP is enabled by default for local images)
|
||||||
|
disableWebP?: boolean; |
||||||
|
// Generate srcset for responsive images (useful for YouTube thumbnails)
|
||||||
|
// If true, will generate srcset with different sizes
|
||||||
|
responsive?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Optimized Image component with WebP support and performance optimizations |
||||||
|
*
|
||||||
|
* Features: |
||||||
|
* - Automatic WebP format with PNG/JPG fallback (for local images) |
||||||
|
* - Proper width/height attributes to prevent layout shift |
||||||
|
* - Async decoding for non-blocking image loading |
||||||
|
* - Lazy loading support |
||||||
|
* - Fetch priority hints |
||||||
|
* - External image optimization |
||||||
|
*
|
||||||
|
* Usage: |
||||||
|
* <OptimizedImage
|
||||||
|
* src="/image.png"
|
||||||
|
* alt="Description" |
||||||
|
* width={80} |
||||||
|
* height={80} |
||||||
|
* loading="lazy" |
||||||
|
* /> |
||||||
|
*
|
||||||
|
* Note: WebP versions should be placed alongside PNG files (e.g., image.png and image.webp) |
||||||
|
*/ |
||||||
|
export function OptimizedImage({ |
||||||
|
src, |
||||||
|
alt, |
||||||
|
width, |
||||||
|
height, |
||||||
|
className = '', |
||||||
|
loading = 'lazy', |
||||||
|
fetchPriority = 'auto', |
||||||
|
decoding = 'async', |
||||||
|
isExternal = false, |
||||||
|
disableWebP = false, |
||||||
|
responsive = false, |
||||||
|
...props |
||||||
|
}: OptimizedImageProps) { |
||||||
|
// Determine the best image source
|
||||||
|
let imageSrc = src; |
||||||
|
|
||||||
|
// For local images, automatically use WebP with fallback (unless disabled)
|
||||||
|
if (!isExternal && !disableWebP) { |
||||||
|
// Generate WebP source path
|
||||||
|
const webpSrc = src.replace(/\.(png|jpg|jpeg)$/i, '.webp'); |
||||||
|
// Use picture element for WebP with fallback
|
||||||
|
// Modern browsers will use WebP from source, older browsers will use PNG from img
|
||||||
|
return ( |
||||||
|
<picture> |
||||||
|
<source srcSet={webpSrc} type="image/webp" /> |
||||||
|
<img |
||||||
|
{...props} |
||||||
|
src={src} |
||||||
|
alt={alt} |
||||||
|
width={width} |
||||||
|
height={height} |
||||||
|
className={className} |
||||||
|
loading={loading} |
||||||
|
fetchPriority={fetchPriority} |
||||||
|
decoding={decoding} |
||||||
|
/> |
||||||
|
</picture> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// For external images (YouTube thumbnails), optimize loading
|
||||||
|
if (isExternal) { |
||||||
|
// Generate srcset for YouTube thumbnails if responsive is enabled
|
||||||
|
// YouTube provides different thumbnail sizes: default, mqdefault, hqdefault, sddefault, maxresdefault
|
||||||
|
let srcSet: string | undefined; |
||||||
|
if (responsive && imageSrc.includes('ytimg.com') || imageSrc.includes('youtube.com')) { |
||||||
|
// Extract the base URL and generate srcset with different YouTube thumbnail sizes
|
||||||
|
const baseUrl = imageSrc.replace(/\/(default|mqdefault|hqdefault|sddefault|maxresdefault)\.jpg/i, ''); |
||||||
|
srcSet = [ |
||||||
|
`${baseUrl}/default.jpg 120w`, |
||||||
|
`${baseUrl}/mqdefault.jpg 320w`, |
||||||
|
`${baseUrl}/hqdefault.jpg 480w`, |
||||||
|
`${baseUrl}/sddefault.jpg 640w` |
||||||
|
].join(', '); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<img |
||||||
|
{...props} |
||||||
|
src={imageSrc} |
||||||
|
alt={alt} |
||||||
|
width={width} |
||||||
|
height={height} |
||||||
|
className={className} |
||||||
|
loading={loading} |
||||||
|
fetchPriority={fetchPriority} |
||||||
|
decoding={decoding} |
||||||
|
srcSet={srcSet} |
||||||
|
sizes={responsive ? '(max-width: 640px) 320px, (max-width: 1024px) 480px, 640px' : undefined} |
||||||
|
// Add referrerpolicy for external images
|
||||||
|
referrerPolicy="no-referrer-when-downgrade" |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// Standard optimized image
|
||||||
|
return ( |
||||||
|
<img |
||||||
|
{...props} |
||||||
|
src={imageSrc} |
||||||
|
alt={alt} |
||||||
|
width={width} |
||||||
|
height={height} |
||||||
|
className={className} |
||||||
|
loading={loading} |
||||||
|
fetchPriority={fetchPriority} |
||||||
|
decoding={decoding} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,400 @@ |
|||||||
|
import { useState, useEffect } from 'react'; |
||||||
|
import { Link } from 'react-router-dom'; |
||||||
|
import { usersApi } from '../services/apiClient'; |
||||||
|
import { AdminUser } from '../types/api'; |
||||||
|
import { useAuth } from '../hooks/useAuth'; |
||||||
|
|
||||||
|
export function UsersAdminPage() { |
||||||
|
const { user: currentUser } = useAuth(); |
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]); |
||||||
|
const [loading, setLoading] = useState(true); |
||||||
|
const [error, setError] = useState<string | null>(null); |
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false); |
||||||
|
const [editingUser, setEditingUser] = useState<AdminUser | null>(null); |
||||||
|
const [changingPasswordFor, setChangingPasswordFor] = useState<number | null>(null); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
loadUsers(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const loadUsers = async () => { |
||||||
|
try { |
||||||
|
setLoading(true); |
||||||
|
setError(null); |
||||||
|
const response: any = await usersApi.getAll(); |
||||||
|
setUsers(response.data); |
||||||
|
} catch (err: any) { |
||||||
|
setError(err.error?.message || 'Failed to load users'); |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const handleDelete = async (userId: number) => { |
||||||
|
if (!confirm('Are you sure you want to delete this user?')) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
await usersApi.delete(userId); |
||||||
|
await loadUsers(); |
||||||
|
} catch (err: any) { |
||||||
|
alert(err.error?.message || 'Failed to delete user'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const formatDate = (dateString: string | undefined) => { |
||||||
|
if (!dateString) return 'Never'; |
||||||
|
return new Date(dateString).toLocaleDateString(); |
||||||
|
}; |
||||||
|
|
||||||
|
if (loading) { |
||||||
|
return ( |
||||||
|
<div className="min-h-[calc(100vh-60px)] bg-background flex items-center justify-center"> |
||||||
|
<div className="text-center"> |
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div> |
||||||
|
<p className="text-muted-foreground">Loading users...</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="min-h-[calc(100vh-60px)] bg-background"> |
||||||
|
<div className="bg-card border-b border-border py-8 px-6 text-center"> |
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="inline-block mb-4 px-4 py-2 bg-transparent border border-border rounded-md text-foreground text-sm cursor-pointer transition-colors no-underline hover:bg-muted" |
||||||
|
> |
||||||
|
← Back to Admin |
||||||
|
</Link> |
||||||
|
<h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">User Management</h1> |
||||||
|
<p className="m-0 text-sm text-muted-foreground">Manage admin and user accounts</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto p-6"> |
||||||
|
<div className="mb-4 flex justify-end"> |
||||||
|
<button |
||||||
|
onClick={() => setShowCreateModal(true)} |
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md" |
||||||
|
> |
||||||
|
+ Create User |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{error && ( |
||||||
|
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive"> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border border-border overflow-hidden"> |
||||||
|
<table className="w-full"> |
||||||
|
<thead className="bg-muted"> |
||||||
|
<tr> |
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Username</th> |
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Role</th> |
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Created</th> |
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Last Login</th> |
||||||
|
<th className="px-6 py-3 text-right text-xs font-semibold text-foreground uppercase">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody className="divide-y divide-border"> |
||||||
|
{users.map((user) => ( |
||||||
|
<tr key={user.id} className="hover:bg-muted/50"> |
||||||
|
<td className="px-6 py-4 text-sm font-medium text-foreground">{user.username}</td> |
||||||
|
<td className="px-6 py-4 text-sm"> |
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${ |
||||||
|
user.role === 'admin'
|
||||||
|
? 'bg-primary/20 text-primary'
|
||||||
|
: 'bg-muted-foreground/20 text-muted-foreground' |
||||||
|
}`}>
|
||||||
|
{user.role === 'admin' ? 'Admin' : 'User'} |
||||||
|
</span> |
||||||
|
</td> |
||||||
|
<td className="px-6 py-4 text-sm text-muted-foreground">{formatDate(user.createdAt)}</td> |
||||||
|
<td className="px-6 py-4 text-sm text-muted-foreground">{formatDate(user.lastLogin)}</td> |
||||||
|
<td className="px-6 py-4 text-sm text-right"> |
||||||
|
<div className="flex items-center justify-end gap-2"> |
||||||
|
<button |
||||||
|
onClick={() => setEditingUser(user)} |
||||||
|
className="px-3 py-1 text-xs font-semibold text-foreground hover:bg-muted rounded-full transition-colors" |
||||||
|
> |
||||||
|
Edit |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onClick={() => setChangingPasswordFor(user.id)} |
||||||
|
className="px-3 py-1 text-xs font-semibold text-primary hover:bg-primary/10 rounded-full transition-colors" |
||||||
|
> |
||||||
|
Password |
||||||
|
</button> |
||||||
|
{user.id !== currentUser?.id && ( |
||||||
|
<button |
||||||
|
onClick={() => handleDelete(user.id)} |
||||||
|
className="px-3 py-1 text-xs font-semibold text-destructive hover:bg-destructive/10 rounded-full transition-colors" |
||||||
|
> |
||||||
|
Delete |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
))} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{showCreateModal && ( |
||||||
|
<UserFormModal |
||||||
|
onClose={() => setShowCreateModal(false)} |
||||||
|
onSuccess={() => { |
||||||
|
setShowCreateModal(false); |
||||||
|
loadUsers(); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{editingUser && ( |
||||||
|
<UserFormModal |
||||||
|
user={editingUser} |
||||||
|
onClose={() => setEditingUser(null)} |
||||||
|
onSuccess={() => { |
||||||
|
setEditingUser(null); |
||||||
|
loadUsers(); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{changingPasswordFor && ( |
||||||
|
<PasswordModal |
||||||
|
userId={changingPasswordFor} |
||||||
|
onClose={() => setChangingPasswordFor(null)} |
||||||
|
onSuccess={() => { |
||||||
|
setChangingPasswordFor(null); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function UserFormModal({ user, onClose, onSuccess }: { user?: AdminUser; onClose: () => void; onSuccess: () => void }) { |
||||||
|
const [username, setUsername] = useState(user?.username || ''); |
||||||
|
const [password, setPassword] = useState(''); |
||||||
|
const [role, setRole] = useState<'admin' | 'user'>(user?.role || 'user'); |
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
const [error, setError] = useState<string | null>(null); |
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => { |
||||||
|
e.preventDefault(); |
||||||
|
setError(null); |
||||||
|
|
||||||
|
if (!username.trim()) { |
||||||
|
setError('Username is required'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!user && !password) { |
||||||
|
setError('Password is required for new users'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (password && password.length < 8) { |
||||||
|
setError('Password must be at least 8 characters'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
setLoading(true); |
||||||
|
if (user) { |
||||||
|
await usersApi.update(user.id, { username, role }); |
||||||
|
} else { |
||||||
|
await usersApi.create({ username, password, role }); |
||||||
|
} |
||||||
|
onSuccess(); |
||||||
|
} catch (err: any) { |
||||||
|
setError(err.error?.message || 'Failed to save user'); |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> |
||||||
|
<div className="bg-card rounded-lg border border-border max-w-md w-full p-6"> |
||||||
|
<h2 className="text-xl font-bold text-foreground mb-4"> |
||||||
|
{user ? 'Edit User' : 'Create User'} |
||||||
|
</h2> |
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}> |
||||||
|
<div className="mb-4"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Username |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
value={username} |
||||||
|
onChange={(e) => setUsername(e.target.value)} |
||||||
|
className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" |
||||||
|
required |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
{!user && ( |
||||||
|
<div className="mb-4"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Password |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="password" |
||||||
|
value={password} |
||||||
|
onChange={(e) => setPassword(e.target.value)} |
||||||
|
className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" |
||||||
|
required |
||||||
|
minLength={8} |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground mt-1">Minimum 8 characters</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="mb-6"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Role |
||||||
|
</label> |
||||||
|
<select |
||||||
|
value={role} |
||||||
|
onChange={(e) => setRole(e.target.value as 'admin' | 'user')} |
||||||
|
className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" |
||||||
|
> |
||||||
|
<option value="user">User</option> |
||||||
|
<option value="admin">Admin</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm"> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={onClose} |
||||||
|
className="px-4 py-2 text-sm font-semibold text-foreground hover:bg-muted rounded-full transition-colors" |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={loading} |
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md disabled:opacity-50" |
||||||
|
> |
||||||
|
{loading ? 'Saving...' : user ? 'Update' : 'Create'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function PasswordModal({ userId, onClose, onSuccess }: { userId: number; onClose: () => void; onSuccess: () => void }) { |
||||||
|
const [password, setPassword] = useState(''); |
||||||
|
const [confirmPassword, setConfirmPassword] = useState(''); |
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
const [error, setError] = useState<string | null>(null); |
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => { |
||||||
|
e.preventDefault(); |
||||||
|
setError(null); |
||||||
|
|
||||||
|
if (!password) { |
||||||
|
setError('Password is required'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (password.length < 8) { |
||||||
|
setError('Password must be at least 8 characters'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (password !== confirmPassword) { |
||||||
|
setError('Passwords do not match'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
setLoading(true); |
||||||
|
await usersApi.changePassword(userId, password); |
||||||
|
onSuccess(); |
||||||
|
alert('Password changed successfully'); |
||||||
|
} catch (err: any) { |
||||||
|
setError(err.error?.message || 'Failed to change password'); |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> |
||||||
|
<div className="bg-card rounded-lg border border-border max-w-md w-full p-6"> |
||||||
|
<h2 className="text-xl font-bold text-foreground mb-4">Change Password</h2> |
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}> |
||||||
|
<div className="mb-4"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
New Password |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="password" |
||||||
|
value={password} |
||||||
|
onChange={(e) => setPassword(e.target.value)} |
||||||
|
className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" |
||||||
|
required |
||||||
|
minLength={8} |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground mt-1">Minimum 8 characters</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mb-6"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Confirm Password |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="password" |
||||||
|
value={confirmPassword} |
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)} |
||||||
|
className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" |
||||||
|
required |
||||||
|
minLength={8} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm"> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={onClose} |
||||||
|
className="px-4 py-2 text-sm font-semibold text-foreground hover:bg-muted rounded-full transition-colors" |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={loading} |
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md disabled:opacity-50" |
||||||
|
> |
||||||
|
{loading ? 'Changing...' : 'Change Password'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
Loading…
Reference in new issue