Browse Source

add ability for multiple roles in admin area

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
45ebba3109
  1. 6
      backend/src/controllers/auth.controller.ts
  2. 410
      backend/src/controllers/users.controller.ts
  3. 188
      backend/src/db/migrate.ts
  4. 2
      backend/src/index.ts
  5. 66
      backend/src/middleware/admin.ts
  6. 9
      backend/src/routes/channels.routes.ts
  7. 9
      backend/src/routes/settings.routes.ts
  8. 21
      backend/src/routes/users.routes.ts
  9. 13
      backend/src/routes/wordGroups.routes.ts
  10. 4
      backend/src/setup/initialSetup.ts
  11. 8
      frontend/index.html
  12. BIN
      frontend/public/cupcake.webp
  13. 24
      frontend/public/manifest.json
  14. BIN
      frontend/public/rainbow.webp
  15. BIN
      frontend/public/tic-tac-toe.webp
  16. BIN
      frontend/public/unicorn-talking.webp
  17. BIN
      frontend/public/video-marketing.webp
  18. 17
      frontend/src/App.tsx
  19. 28
      frontend/src/components/Navbar/Navbar.tsx
  20. 125
      frontend/src/components/OptimizedImage/OptimizedImage.tsx
  21. 14
      frontend/src/components/ProtectedRoute.tsx
  22. 4
      frontend/src/hooks/useAuth.tsx
  23. 26
      frontend/src/pages/AdminPage.tsx
  24. 19
      frontend/src/pages/LandingPage.tsx
  25. 400
      frontend/src/pages/UsersAdminPage.tsx
  26. 17
      frontend/src/services/apiClient.ts
  27. 9
      frontend/src/types/api.ts

6
backend/src/controllers/auth.controller.ts

@ -65,7 +65,8 @@ export async function login(req: AuthRequest, res: Response) { @@ -65,7 +65,8 @@ export async function login(req: AuthRequest, res: Response) {
data: {
user: {
id: user.id,
username: user.username
username: user.username,
role: user.role || 'user'
},
accessToken,
refreshToken
@ -162,7 +163,7 @@ export async function getCurrentUser(req: AuthRequest, res: Response) { @@ -162,7 +163,7 @@ export async function getCurrentUser(req: AuthRequest, res: Response) {
}
const result = await db.execute({
sql: 'SELECT id, username, last_login FROM users WHERE id = ?',
sql: 'SELECT id, username, role, last_login FROM users WHERE id = ?',
args: [req.userId]
});
@ -183,6 +184,7 @@ export async function getCurrentUser(req: AuthRequest, res: Response) { @@ -183,6 +184,7 @@ export async function getCurrentUser(req: AuthRequest, res: Response) {
data: {
id: user.id,
username: user.username,
role: user.role || 'user',
lastLogin: user.last_login
}
});

410
backend/src/controllers/users.controller.ts

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

188
backend/src/db/migrate.ts

@ -164,6 +164,84 @@ const migrations = [ @@ -164,6 +164,84 @@ const migrations = [
await db.execute('CREATE INDEX IF NOT EXISTS idx_words_group_id ON words(word_group_id)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_words_word ON words(word)');
}
},
{
id: 4,
name: 'add_user_roles',
up: async () => {
try {
console.log(' Checking if role column exists...');
const columnCheck = await db.execute(`
SELECT 1 FROM pragma_table_info('users')
WHERE name = 'role'
`);
if (!columnCheck.rows.length) {
console.log(' Role column not found, adding it...');
// Add role column with default 'user'
await db.execute(`
ALTER TABLE users
ADD COLUMN role TEXT DEFAULT 'user' NOT NULL
`);
console.log(' ✓ Role column added');
// Create index on role for faster queries
await db.execute('CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)');
console.log(' ✓ Index created');
// Set all existing users to 'admin' (backward compatibility)
// This ensures existing admin users remain admins
const updateResult = await db.execute(`
UPDATE users
SET role = 'admin'
`);
console.log(` ✓ Updated existing users to admin role`);
} else {
console.log(' ✓ Role column already exists, skipping');
}
} catch (error: any) {
console.error(' ❌ Error in migration:', error.message);
throw error;
}
}
},
{
id: 5,
name: 'drop_pets_table',
up: async () => {
try {
console.log(' Checking if pets table exists...');
// Check if pets table exists by trying to query sqlite_master
const tableCheck = await db.execute(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='pets'
`);
console.log(` Table check result: ${tableCheck.rows.length} rows found`);
if (tableCheck.rows.length > 0) {
console.log(' Pets table found, dropping it...');
await db.execute('DROP TABLE IF EXISTS pets');
console.log(' ✓ Pets table dropped');
// Verify it was actually dropped
const verifyCheck = await db.execute(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='pets'
`);
if (verifyCheck.rows.length === 0) {
console.log(' ✓ Verified: pets table successfully removed');
} else {
throw new Error('DROP TABLE appeared to succeed but table still exists');
}
} else {
console.log(' ✓ Pets table does not exist, skipping');
}
} catch (error: any) {
console.error(' ❌ Error in migration:', error.message);
console.error(' Full error:', error);
throw error;
}
}
}
];
@ -178,22 +256,116 @@ export async function runMigrations() { @@ -178,22 +256,116 @@ export async function runMigrations() {
`);
// Get executed migrations
const executed = await db.execute('SELECT id FROM migrations');
const executed = await db.execute('SELECT id, name FROM migrations ORDER BY id');
const executedIds = new Set(executed.rows.map(r => r.id));
if (executed.rows.length > 0) {
console.log('Already executed migrations:');
executed.rows.forEach((row: any) => {
console.log(` - ${row.id}: ${row.name}`);
});
}
// Run pending migrations
for (const migration of migrations) {
if (!executedIds.has(migration.id)) {
console.log(`Running migration: ${migration.name}...`);
await migration.up();
await db.execute({
sql: 'INSERT INTO migrations (id, name) VALUES (?, ?)',
args: [migration.id, migration.name]
console.log(`\n🔄 Running migration: ${migration.name} (id: ${migration.id})...`);
try {
await migration.up();
await db.execute({
sql: 'INSERT INTO migrations (id, name) VALUES (?, ?)',
args: [migration.id, migration.name]
});
console.log(`✅ Migration ${migration.name} completed\n`);
} catch (error: any) {
console.error(`❌ Migration ${migration.name} failed:`, error);
console.error('Error details:', error.message);
throw error;
}
} else {
console.log(` Migration ${migration.name} (id: ${migration.id}) already executed, skipping`);
}
}
console.log('✅ All migrations completed');
// Verify critical migrations actually worked - fix if needed
try {
// Verify role column exists
await db.execute('SELECT role FROM users LIMIT 1');
console.log('✅ Verified: role column exists');
} catch (error: any) {
if (error.message?.includes('no such column: role') || error.code === 'SQL_INPUT_ERROR') {
console.error('⚠ WARNING: Migration 4 marked as executed but role column missing!');
console.log('🔧 Attempting to fix by running migration 4 again...');
// Check if migration 4 is marked as executed
const migration4Check = await db.execute({
sql: 'SELECT id FROM migrations WHERE id = ?',
args: [4]
});
console.log(`✓ Migration ${migration.name} completed`);
if (migration4Check.rows.length > 0) {
// Migration is marked as executed but column doesn't exist - fix it
console.log(' Removing migration 4 from tracking table...');
await db.execute({
sql: 'DELETE FROM migrations WHERE id = ?',
args: [4]
});
console.log(' Re-running migration 4...');
const migration4 = migrations.find(m => m.id === 4);
if (migration4) {
await migration4.up();
await db.execute({
sql: 'INSERT INTO migrations (id, name) VALUES (?, ?)',
args: [4, 'add_user_roles']
});
console.log('✅ Migration 4 fixed and completed');
}
}
}
}
console.log('✓ All migrations completed');
// Verify pets table was removed
try {
const petsCheck = await db.execute(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='pets'
`);
if (petsCheck.rows.length > 0) {
console.error('⚠ WARNING: Migration 5 marked as executed but pets table still exists!');
console.log('🔧 Attempting to fix by running migration 5 again...');
// Check if migration 5 is marked as executed
const migration5Check = await db.execute({
sql: 'SELECT id FROM migrations WHERE id = ?',
args: [5]
});
if (migration5Check.rows.length > 0) {
// Migration is marked as executed but table still exists - fix it
console.log(' Removing migration 5 from tracking table...');
await db.execute({
sql: 'DELETE FROM migrations WHERE id = ?',
args: [5]
});
console.log(' Re-running migration 5...');
const migration5 = migrations.find(m => m.id === 5);
if (migration5) {
await migration5.up();
await db.execute({
sql: 'INSERT INTO migrations (id, name) VALUES (?, ?)',
args: [5, 'drop_pets_table']
});
console.log('✅ Migration 5 fixed and completed');
}
}
} else {
console.log('✅ Verified: pets table does not exist');
}
} catch (error: any) {
console.error('⚠ Error verifying pets table removal:', error.message);
}
}

2
backend/src/index.ts

@ -10,6 +10,7 @@ import channelRoutes from './routes/channels.routes.js'; @@ -10,6 +10,7 @@ import channelRoutes from './routes/channels.routes.js';
import videoRoutes from './routes/videos.routes.js';
import settingsRoutes from './routes/settings.routes.js';
import wordGroupsRoutes from './routes/wordGroups.routes.js';
import usersRoutes from './routes/users.routes.js';
import { errorHandler } from './middleware/errorHandler.js';
import { apiLimiter } from './middleware/rateLimiter.js';
import { createWebSocketServer } from './services/websocket.service.js';
@ -50,6 +51,7 @@ async function startServer() { @@ -50,6 +51,7 @@ async function startServer() {
app.use('/api/videos', videoRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/word-groups', wordGroupsRoutes);
app.use('/api/users', usersRoutes);
// Error handling
app.use(errorHandler);

66
backend/src/middleware/admin.ts

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

9
backend/src/routes/channels.routes.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { Router } from 'express';
import { getAllChannels, addChannel, deleteChannel, refreshChannel } from '../controllers/channels.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { adminMiddleware } from '../middleware/admin.js';
import { validateRequest, addChannelSchema } from '../middleware/validation.js';
const router = Router();
@ -8,10 +9,10 @@ const router = Router(); @@ -8,10 +9,10 @@ const router = Router();
// Public route
router.get('/', getAllChannels);
// Protected routes
router.post('/', authMiddleware, validateRequest(addChannelSchema), addChannel);
router.delete('/:id', authMiddleware, deleteChannel);
router.put('/:id/refresh', authMiddleware, refreshChannel);
// Admin-only routes
router.post('/', authMiddleware, adminMiddleware, validateRequest(addChannelSchema), addChannel);
router.delete('/:id', authMiddleware, adminMiddleware, deleteChannel);
router.put('/:id/refresh', authMiddleware, adminMiddleware, refreshChannel);
export default router;

9
backend/src/routes/settings.routes.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { Router } from 'express';
import { getTimeLimit, setTimeLimit, heartbeat, getConnectionStats } from '../controllers/settings.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { adminMiddleware } from '../middleware/admin.js';
import { optionalAuthMiddleware } from '../middleware/optionalAuth.js';
const router = Router();
@ -8,13 +9,13 @@ const router = Router(); @@ -8,13 +9,13 @@ const router = Router();
// Public route - anyone can read the time limit
router.get('/time-limit', getTimeLimit);
// Protected route - only admins can set time limits
router.put('/time-limit', authMiddleware, setTimeLimit);
// Admin-only route - only admins can set time limits
router.put('/time-limit', authMiddleware, adminMiddleware, setTimeLimit);
// Public route - heartbeat for connection tracking (optional auth to track authenticated users)
router.post('/heartbeat', optionalAuthMiddleware, heartbeat);
// Protected route - admin only - get connection stats
router.get('/connection-stats', authMiddleware, getConnectionStats);
// Admin-only route - get connection stats
router.get('/connection-stats', authMiddleware, adminMiddleware, getConnectionStats);
export default router;

21
backend/src/routes/users.routes.ts

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

13
backend/src/routes/wordGroups.routes.ts

@ -8,22 +8,23 @@ import { @@ -8,22 +8,23 @@ import {
deleteWord
} from '../controllers/wordGroups.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { adminMiddleware } from '../middleware/admin.js';
const router = Router();
// Public route - anyone can read word groups
router.get('/', getAllWordGroups);
// Protected routes - only admins can create/update/delete
router.post('/', authMiddleware, createWordGroup);
// Admin-only routes - only admins can create/update/delete
router.post('/', authMiddleware, adminMiddleware, createWordGroup);
// Word routes - must come before generic :id routes
// More specific routes first
router.post('/:groupId/words', authMiddleware, addWord);
router.delete('/words/:wordId', authMiddleware, deleteWord);
router.post('/:groupId/words', authMiddleware, adminMiddleware, addWord);
router.delete('/words/:wordId', authMiddleware, adminMiddleware, deleteWord);
// Word group routes with IDs (generic routes last)
router.put('/:id', authMiddleware, updateWordGroup);
router.delete('/:id', authMiddleware, deleteWordGroup);
router.put('/:id', authMiddleware, adminMiddleware, updateWordGroup);
router.delete('/:id', authMiddleware, adminMiddleware, deleteWordGroup);
export default router;

4
backend/src/setup/initialSetup.ts

@ -18,8 +18,8 @@ export async function createInitialAdmin() { @@ -18,8 +18,8 @@ export async function createInitialAdmin() {
const hash = await bcrypt.hash(password, 10);
await db.execute({
sql: 'INSERT INTO users (username, password_hash) VALUES (?, ?)',
args: [username, hash]
sql: 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
args: [username, hash, 'admin']
});
console.log(`✓ Initial admin user created: ${username}`);

8
frontend/index.html

@ -9,11 +9,11 @@ @@ -9,11 +9,11 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
<!-- Critical images preload -->
<link rel="preload" href="/rainbow.png" as="image" type="image/png" fetchpriority="high">
<link rel="preload" href="/cupcake.png" as="image" type="image/png" fetchpriority="high">
<!-- Critical images preload - WebP for modern browsers -->
<link rel="preload" href="/rainbow.webp" as="image" type="image/webp" fetchpriority="high">
<link rel="preload" href="/cupcake.webp" as="image" type="image/webp" fetchpriority="high">
<!-- Preload first game icon (likely LCP element) -->
<link rel="preload" href="/video-marketing.png" as="image" type="image/png" fetchpriority="high">
<link rel="preload" href="/video-marketing.webp" as="image" type="image/webp" fetchpriority="high">
<!-- Async font loading -->
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Butterfly+Kids&display=swap&text=Rainbows%2CCupcakesUnicorns" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link href="https://fonts.googleapis.com/css2?family=Butterfly+Kids&display=swap" rel="stylesheet"></noscript>

BIN
frontend/public/cupcake.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

24
frontend/public/manifest.json

@ -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"
}
]
}

BIN
frontend/public/rainbow.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
frontend/public/tic-tac-toe.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
frontend/public/unicorn-talking.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
frontend/public/video-marketing.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

17
frontend/src/App.tsx

@ -15,6 +15,7 @@ const AdminPage = lazy(() => import('./pages/AdminPage').then(module => ({ defau @@ -15,6 +15,7 @@ const AdminPage = lazy(() => import('./pages/AdminPage').then(module => ({ defau
const VideosAdminPage = lazy(() => import('./pages/VideosAdminPage').then(module => ({ default: module.VideosAdminPage })));
const SpeechSoundsAdminPage = lazy(() => import('./pages/SpeechSoundsAdminPage').then(module => ({ default: module.SpeechSoundsAdminPage })));
const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module => ({ default: module.StatsAdminPage })));
const UsersAdminPage = lazy(() => import('./pages/UsersAdminPage').then(module => ({ default: module.UsersAdminPage })));
const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage })));
// Loading fallback component
@ -65,7 +66,7 @@ function App() { @@ -65,7 +66,7 @@ function App() {
<Route
path="/admin"
element={
<ProtectedRoute>
<ProtectedRoute requireAdmin={true}>
<AdminPage />
</ProtectedRoute>
}
@ -73,7 +74,7 @@ function App() { @@ -73,7 +74,7 @@ function App() {
<Route
path="/admin/videos"
element={
<ProtectedRoute>
<ProtectedRoute requireAdmin={true}>
<VideosAdminPage />
</ProtectedRoute>
}
@ -81,7 +82,7 @@ function App() { @@ -81,7 +82,7 @@ function App() {
<Route
path="/admin/speech-sounds"
element={
<ProtectedRoute>
<ProtectedRoute requireAdmin={true}>
<SpeechSoundsAdminPage />
</ProtectedRoute>
}
@ -89,11 +90,19 @@ function App() { @@ -89,11 +90,19 @@ function App() {
<Route
path="/admin/stats"
element={
<ProtectedRoute>
<ProtectedRoute requireAdmin={true}>
<StatsAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/users"
element={
<ProtectedRoute requireAdmin={true}>
<UsersAdminPage />
</ProtectedRoute>
}
/>
</Routes>
</Suspense>
</main>

28
frontend/src/components/Navbar/Navbar.tsx

@ -3,9 +3,10 @@ import { Link, useLocation, useSearchParams } from 'react-router-dom'; @@ -3,9 +3,10 @@ import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
import { useChannels } from '../../hooks/useChannels';
import { APPS } from '../../config/apps';
import { OptimizedImage } from '../OptimizedImage/OptimizedImage';
export function Navbar() {
const { isAuthenticated, logout } = useAuth();
const { isAuthenticated, logout, isAdmin } = useAuth();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { channels } = useChannels();
@ -72,22 +73,22 @@ export function Navbar() { @@ -72,22 +73,22 @@ export function Navbar() {
<div className="max-w-5xl mx-auto px-4 py-5">
<div className="flex items-center gap-3 justify-between">
<Link to="/" className="flex items-center gap-3">
<img
<OptimizedImage
src="/rainbow.png"
alt="Rainbow"
className="h-10 w-10 md:h-12 md:w-12 object-contain"
width="48"
height="48"
width={48}
height={48}
loading="eager"
fetchPriority="high"
/>
<h1 className="text-3xl md:text-4xl font-bold text-foreground" style={{ fontFamily: "'Butterfly Kids', cursive" }}>Rainbows, Cupcakes & Unicorns</h1>
<img
<OptimizedImage
src="/cupcake.png"
alt="Cupcake"
className="h-10 w-10 md:h-12 md:w-12 object-contain"
width="48"
height="48"
width={48}
height={48}
loading="eager"
fetchPriority="high"
/>
@ -105,6 +106,19 @@ export function Navbar() { @@ -105,6 +106,19 @@ export function Navbar() {
Home
</Link>
{isAdmin && (
<Link
to="/admin"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${
location.pathname.startsWith('/admin')
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`}
>
Admin
</Link>
)}
{isAuthenticated ? (
<button
onClick={handleLogout}

125
frontend/src/components/OptimizedImage/OptimizedImage.tsx

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

14
frontend/src/components/ProtectedRoute.tsx

@ -3,10 +3,11 @@ import { useAuth } from '../hooks/useAuth'; @@ -3,10 +3,11 @@ import { useAuth } from '../hooks/useAuth';
interface ProtectedRouteProps {
children: React.ReactNode;
requireAdmin?: boolean;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, loading } = useAuth();
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
const { isAuthenticated, loading, isAdmin } = useAuth();
if (loading) {
return <div style={{ padding: '48px', textAlign: 'center' }}>Loading...</div>;
@ -16,6 +17,15 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) { @@ -16,6 +17,15 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
return <Navigate to="/login" replace />;
}
if (requireAdmin && !isAdmin) {
return (
<div style={{ padding: '48px', textAlign: 'center' }}>
<h1>Access Denied</h1>
<p>You need admin privileges to access this page.</p>
</div>
);
}
return <>{children}</>;
}

4
frontend/src/hooks/useAuth.tsx

@ -8,6 +8,7 @@ interface AuthContextType { @@ -8,6 +8,7 @@ interface AuthContextType {
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isAuthenticated: boolean;
isAdmin: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
@ -76,7 +77,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { @@ -76,7 +77,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
loading,
login,
logout,
isAuthenticated: !!user
isAuthenticated: !!user,
isAdmin: user?.role === 'admin'
}}>
{children}
</AuthContext.Provider>

26
frontend/src/pages/AdminPage.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage';
export function AdminPage() {
return (
@ -14,10 +15,13 @@ export function AdminPage() { @@ -14,10 +15,13 @@ export function AdminPage() {
className="bg-pink-100 hover:bg-pink-200 w-full p-6 rounded-3xl font-semibold text-foreground transition-all active:scale-95 hover:shadow-lg flex flex-col items-center text-center no-underline"
>
<div className="mb-3">
<img
<OptimizedImage
src="/video-marketing.png"
alt="Video App"
className="w-20 h-20 object-contain"
width={80}
height={80}
loading="lazy"
/>
</div>
<h2 className="text-xl font-bold mb-1">Video App</h2>
@ -31,10 +35,13 @@ export function AdminPage() { @@ -31,10 +35,13 @@ export function AdminPage() {
className="bg-purple-100 hover:bg-purple-200 w-full p-6 rounded-3xl font-semibold text-foreground transition-all active:scale-95 hover:shadow-lg flex flex-col items-center text-center no-underline"
>
<div className="mb-3">
<img
<OptimizedImage
src="/unicorn-talking.png"
alt="Speech Sounds"
className="w-20 h-20 object-contain"
width={80}
height={80}
loading="lazy"
/>
</div>
<h2 className="text-xl font-bold mb-1">Speech Sounds</h2>
@ -57,6 +64,21 @@ export function AdminPage() { @@ -57,6 +64,21 @@ export function AdminPage() {
View active user connections and routes
</p>
</Link>
<Link
to="/admin/users"
className="bg-green-100 hover:bg-green-200 w-full p-6 rounded-3xl font-semibold text-foreground transition-all active:scale-95 hover:shadow-lg flex flex-col items-center text-center no-underline"
>
<div className="mb-3">
<div className="w-20 h-20 flex items-center justify-center text-4xl">
👥
</div>
</div>
<h2 className="text-xl font-bold mb-1">User Management</h2>
<p className="text-sm opacity-75">
Manage admin and user accounts
</p>
</Link>
</div>
</div>
);

19
frontend/src/pages/LandingPage.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { Link } from 'react-router-dom';
import { APPS } from '../config/apps';
import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage';
const categoryEmojis: { [key: string]: string } = {
videos: '📺',
@ -49,32 +50,32 @@ export function LandingPage() { @@ -49,32 +50,32 @@ export function LandingPage() {
>
<div className="mb-3">
{app.id === 'videos' ? (
<img
<OptimizedImage
src="/video-marketing.png"
alt="Video App"
className="w-20 h-20 object-contain"
width="80"
height="80"
width={80}
height={80}
loading="eager"
fetchPriority={app.id === 'videos' ? 'high' : 'auto'}
/>
) : app.id === 'speechsounds' ? (
<img
<OptimizedImage
src="/unicorn-talking.png"
alt="Speech Sounds"
className="w-20 h-20 object-contain"
width="80"
height="80"
width={80}
height={80}
loading="eager"
fetchPriority="auto"
/>
) : app.id === 'tictactoe' ? (
<img
<OptimizedImage
src="/tic-tac-toe.png"
alt="Tic Tac Toe"
className="w-20 h-20 object-contain"
width="80"
height="80"
width={80}
height={80}
loading="eager"
fetchPriority="auto"
/>

400
frontend/src/pages/UsersAdminPage.tsx

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

17
frontend/src/services/apiClient.ts

@ -154,3 +154,20 @@ export const wordGroupsApi = { @@ -154,3 +154,20 @@ export const wordGroupsApi = {
api.delete(`/word-groups/words/${wordId}`)
};
// Users API (admin only)
export const usersApi = {
getAll: () => api.get('/users'),
create: (userData: { username: string; password: string; role?: 'admin' | 'user' }) =>
api.post('/users', userData),
update: (id: number, userData: { username?: string; role?: 'admin' | 'user' }) =>
api.put(`/users/${id}`, userData),
delete: (id: number) =>
api.delete(`/users/${id}`),
changePassword: (id: number, password: string) =>
api.put(`/users/${id}/password`, { password })
};

9
frontend/src/types/api.ts

@ -30,6 +30,15 @@ export interface Video { @@ -30,6 +30,15 @@ export interface Video {
export interface User {
id: number;
username: string;
role?: 'admin' | 'user';
lastLogin?: string;
}
export interface AdminUser {
id: number;
username: string;
role: 'admin' | 'user';
createdAt: string;
lastLogin?: string;
}

Loading…
Cancel
Save