feat: implement comprehensive admin functionality
- Add admin role system with role and isAdmin fields to users table - Create admin_actions audit log table for tracking all admin operations - Implement admin CLI tool for user management (create, promote, demote, list, check) - Add admin authentication with role-based access control - Create admin service layer with system statistics and user management - Implement user impersonation system with proper security checks - Add admin API endpoints for user management and system statistics - Create admin dashboard UI with overview, users, and action logs - Fix admin stats endpoint and user deletion with proper foreign key handling - Add admin link to navigation bar for admin users Database: - Add role and isAdmin columns to users table - Create admin_actions table for audit trail - Migration script: add-admin-functionality.js CLI: - src/backend/scripts/admin-cli.js - Admin user management tool Backend: - src/backend/services/admin.service.js - Admin business logic - Updated auth.service.js with admin helper functions - Enhanced index.js with admin routes and middleware - Export sqlite connection from config for raw SQL operations Frontend: - src/frontend/src/routes/admin/+page.svelte - Admin dashboard - Updated api.js with adminAPI functions - Added Admin link to navigation bar Security: - Admin-only endpoints with role verification - Audit logging for all admin actions - Impersonation with 1-hour token expiration - Foreign key constraint handling for user deletion - Cannot delete self or other admins - Last admin protection
This commit is contained in:
@@ -13,6 +13,17 @@ import {
|
||||
updateLoTWCredentials,
|
||||
updateDCLCredentials,
|
||||
} from './services/auth.service.js';
|
||||
import {
|
||||
getSystemStats,
|
||||
getUserStats,
|
||||
impersonateUser,
|
||||
verifyImpersonation,
|
||||
stopImpersonation,
|
||||
getImpersonationStatus,
|
||||
changeUserRole,
|
||||
deleteUser,
|
||||
} from './services/admin.service.js';
|
||||
import { getAllUsers } from './services/auth.service.js';
|
||||
import {
|
||||
getUserQSOs,
|
||||
getQSOStats,
|
||||
@@ -176,12 +187,19 @@ const app = new Elysia()
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
// Check if this is an impersonation token
|
||||
const isImpersonation = !!payload.impersonatedBy;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: payload.userId,
|
||||
email: payload.email,
|
||||
callsign: payload.callsign,
|
||||
role: payload.role,
|
||||
isAdmin: payload.isAdmin,
|
||||
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
|
||||
},
|
||||
isImpersonation,
|
||||
};
|
||||
} catch (error) {
|
||||
return { user: null };
|
||||
@@ -382,6 +400,8 @@ const app = new Elysia()
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
callsign: user.callsign,
|
||||
role: user.role,
|
||||
isAdmin: user.isAdmin,
|
||||
exp,
|
||||
});
|
||||
|
||||
@@ -1019,6 +1039,366 @@ const app = new Elysia()
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* ================================================================
|
||||
* ADMIN ROUTES
|
||||
* ================================================================
|
||||
* All admin routes require authentication and admin role
|
||||
*/
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats
|
||||
* Get system-wide statistics (admin only)
|
||||
*/
|
||||
.get('/api/admin/stats', async ({ user, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getSystemStats();
|
||||
return {
|
||||
success: true,
|
||||
stats,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching system stats', { error: error.message, userId: user.id });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch system statistics',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
* Get all users with statistics (admin only)
|
||||
*/
|
||||
.get('/api/admin/users', async ({ user, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await getUserStats();
|
||||
return {
|
||||
success: true,
|
||||
users,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching users', { error: error.message, userId: user.id });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch users',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:userId
|
||||
* Get detailed information about a specific user (admin only)
|
||||
*/
|
||||
.get('/api/admin/users/:userId', async ({ user, params, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
const userId = parseInt(params.userId, 10);
|
||||
if (isNaN(userId) || userId <= 0) {
|
||||
set.status = 400;
|
||||
return { success: false, error: 'Invalid user ID' };
|
||||
}
|
||||
|
||||
try {
|
||||
const targetUser = await getAllUsers();
|
||||
const userDetails = targetUser.find(u => u.id === userId);
|
||||
|
||||
if (!userDetails) {
|
||||
set.status = 404;
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: userDetails,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user details', { error: error.message, userId: user.id });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch user details',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/admin/users/:userId/role
|
||||
* Update user role (admin only)
|
||||
*/
|
||||
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
const targetUserId = parseInt(params.userId, 10);
|
||||
if (isNaN(targetUserId) || targetUserId <= 0) {
|
||||
set.status = 400;
|
||||
return { success: false, error: 'Invalid user ID' };
|
||||
}
|
||||
|
||||
const { role, isAdmin } = body;
|
||||
|
||||
if (!role || typeof isAdmin !== 'boolean') {
|
||||
set.status = 400;
|
||||
return { success: false, error: 'role and isAdmin are required' };
|
||||
}
|
||||
|
||||
if (!['user', 'admin'].includes(role)) {
|
||||
set.status = 400;
|
||||
return { success: false, error: 'Invalid role' };
|
||||
}
|
||||
|
||||
try {
|
||||
await changeUserRole(user.id, targetUserId, role, isAdmin);
|
||||
return {
|
||||
success: true,
|
||||
message: 'User role updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error updating user role', { error: error.message, userId: user.id });
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/:userId
|
||||
* Delete a user (admin only)
|
||||
*/
|
||||
.delete('/api/admin/users/:userId', async ({ user, params, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
const targetUserId = parseInt(params.userId, 10);
|
||||
if (isNaN(targetUserId) || targetUserId <= 0) {
|
||||
set.status = 400;
|
||||
return { success: false, error: 'Invalid user ID' };
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteUser(user.id, targetUserId);
|
||||
return {
|
||||
success: true,
|
||||
message: 'User deleted successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error deleting user', { error: error.message, userId: user.id });
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/admin/impersonate/:userId
|
||||
* Start impersonating a user (admin only)
|
||||
*/
|
||||
.post('/api/admin/impersonate/:userId', async ({ user, params, jwt, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
const targetUserId = parseInt(params.userId, 10);
|
||||
if (isNaN(targetUserId) || targetUserId <= 0) {
|
||||
set.status = 400;
|
||||
return { success: false, error: 'Invalid user ID' };
|
||||
}
|
||||
|
||||
try {
|
||||
const targetUser = await impersonateUser(user.id, targetUserId);
|
||||
|
||||
// Generate impersonation token with shorter expiration (1 hour)
|
||||
const exp = Math.floor(Date.now() / 1000) + (60 * 60); // 1 hour from now
|
||||
const token = await jwt.sign({
|
||||
userId: targetUser.id,
|
||||
email: targetUser.email,
|
||||
callsign: targetUser.callsign,
|
||||
role: targetUser.role,
|
||||
isAdmin: targetUser.isAdmin,
|
||||
impersonatedBy: user.id, // Admin ID who started impersonation
|
||||
exp,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
impersonating: {
|
||||
userId: targetUser.id,
|
||||
email: targetUser.email,
|
||||
callsign: targetUser.callsign,
|
||||
},
|
||||
message: `Impersonating ${targetUser.email}`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error starting impersonation', { error: error.message, userId: user.id });
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/admin/impersonate/stop
|
||||
* Stop impersonating and return to admin account (admin only)
|
||||
*/
|
||||
.post('/api/admin/impersonate/stop', async ({ user, jwt, body, set }) => {
|
||||
if (!user || !user.impersonatedBy) {
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not currently impersonating a user',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Log impersonation stop
|
||||
await stopImpersonation(user.impersonatedBy, user.id);
|
||||
|
||||
// Get admin user details to generate new token
|
||||
const adminUsers = await getAllUsers();
|
||||
const adminUser = adminUsers.find(u => u.id === user.impersonatedBy);
|
||||
|
||||
if (!adminUser) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Admin account not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Generate new admin token (24 hours)
|
||||
const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60);
|
||||
const token = await jwt.sign({
|
||||
userId: adminUser.id,
|
||||
email: adminUser.email,
|
||||
callsign: adminUser.callsign,
|
||||
role: adminUser.role,
|
||||
isAdmin: adminUser.isAdmin,
|
||||
exp,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user: adminUser,
|
||||
message: 'Impersonation stopped. Returned to admin account.',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error stopping impersonation', { error: error.message });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to stop impersonation',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/admin/impersonation/status
|
||||
* Get current impersonation status
|
||||
*/
|
||||
.get('/api/admin/impersonation/status', async ({ user }) => {
|
||||
if (!user) {
|
||||
return {
|
||||
success: true,
|
||||
impersonating: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isImpersonating = !!user.impersonatedBy;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
impersonating: isImpersonating,
|
||||
impersonatedBy: user.impersonatedBy,
|
||||
};
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/admin/actions
|
||||
* Get admin actions log (admin only)
|
||||
*/
|
||||
.get('/api/admin/actions', async ({ user, set, query }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
const limit = parseInt(query.limit || '50', 10);
|
||||
const offset = parseInt(query.offset || '0', 10);
|
||||
|
||||
try {
|
||||
const actions = await getAdminActions(null, { limit, offset });
|
||||
return {
|
||||
success: true,
|
||||
actions,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching admin actions', { error: error.message, userId: user.id });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch admin actions',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/admin/actions/my
|
||||
* Get current admin's action log (admin only)
|
||||
*/
|
||||
.get('/api/admin/actions/my', async ({ user, set, query }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
const limit = parseInt(query.limit || '50', 10);
|
||||
const offset = parseInt(query.offset || '0', 10);
|
||||
|
||||
try {
|
||||
const actions = await getAdminActions(user.id, { limit, offset });
|
||||
return {
|
||||
success: true,
|
||||
actions,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching admin actions', { error: error.message, userId: user.id });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch admin actions',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
// Serve static files and SPA fallback for all non-API routes
|
||||
.get('/*', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
Reference in New Issue
Block a user