27 changed files with 1364 additions and 55 deletions
@ -0,0 +1,410 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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