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:
2026-01-21 09:43:56 +01:00
parent fe305310b9
commit e88537754f
10 changed files with 2314 additions and 1 deletions

View File

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