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:
392
src/backend/services/admin.service.js
Normal file
392
src/backend/services/admin.service.js
Normal file
@@ -0,0 +1,392 @@
|
||||
import { eq, sql, desc } from 'drizzle-orm';
|
||||
import { db, sqlite, logger } from '../config.js';
|
||||
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
|
||||
import { getUserByIdFull, isAdmin } from './auth.service.js';
|
||||
|
||||
/**
|
||||
* Log an admin action for audit trail
|
||||
* @param {number} adminId - Admin user ID
|
||||
* @param {string} actionType - Type of action (e.g., 'impersonate_start', 'role_change')
|
||||
* @param {number|null} targetUserId - Target user ID (if applicable)
|
||||
* @param {Object} details - Additional details (will be JSON stringified)
|
||||
* @returns {Promise<Object>} Created admin action record
|
||||
*/
|
||||
export async function logAdminAction(adminId, actionType, targetUserId = null, details = {}) {
|
||||
const [action] = await db
|
||||
.insert(adminActions)
|
||||
.values({
|
||||
adminId,
|
||||
actionType,
|
||||
targetUserId,
|
||||
details: JSON.stringify(details),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin actions log
|
||||
* @param {number} adminId - Admin user ID (optional, if null returns all actions)
|
||||
* @param {Object} options - Query options
|
||||
* @param {number} options.limit - Number of records to return
|
||||
* @param {number} options.offset - Number of records to skip
|
||||
* @returns {Promise<Array>} Array of admin actions
|
||||
*/
|
||||
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
|
||||
let query = db
|
||||
.select({
|
||||
id: adminActions.id,
|
||||
adminId: adminActions.adminId,
|
||||
adminEmail: users.email,
|
||||
adminCallsign: users.callsign,
|
||||
actionType: adminActions.actionType,
|
||||
targetUserId: adminActions.targetUserId,
|
||||
targetEmail: sql`target_users.email`.as('targetEmail'),
|
||||
targetCallsign: sql`target_users.callsign`.as('targetCallsign'),
|
||||
details: adminActions.details,
|
||||
createdAt: adminActions.createdAt,
|
||||
})
|
||||
.from(adminActions)
|
||||
.leftJoin(users, eq(adminActions.adminId, users.id))
|
||||
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
||||
.orderBy(desc(adminActions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
if (adminId) {
|
||||
query = query.where(eq(adminActions.adminId, adminId));
|
||||
}
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system-wide statistics
|
||||
* @returns {Promise<Object>} System statistics
|
||||
*/
|
||||
export async function getSystemStats() {
|
||||
const [
|
||||
userStats,
|
||||
qsoStats,
|
||||
syncJobStats,
|
||||
adminStats,
|
||||
] = await Promise.all([
|
||||
// User statistics
|
||||
db.select({
|
||||
totalUsers: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||
adminUsers: sql`CAST(SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
regularUsers: sql`CAST(SUM(CASE WHEN is_admin = 0 THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
}).from(users),
|
||||
|
||||
// QSO statistics
|
||||
db.select({
|
||||
totalQSOs: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||
uniqueCallsigns: sql`CAST(COUNT(DISTINCT callsign) AS INTEGER)`,
|
||||
uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
|
||||
lotwConfirmed: sql`CAST(SUM(CASE WHEN lotw_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
dclConfirmed: sql`CAST(SUM(CASE WHEN dcl_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
}).from(qsos),
|
||||
|
||||
// Sync job statistics
|
||||
db.select({
|
||||
totalJobs: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||
lotwJobs: sql`CAST(SUM(CASE WHEN type = 'lotw_sync' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
dclJobs: sql`CAST(SUM(CASE WHEN type = 'dcl_sync' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
completedJobs: sql`CAST(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
failedJobs: sql`CAST(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
}).from(syncJobs),
|
||||
|
||||
// Admin action statistics
|
||||
db.select({
|
||||
totalAdminActions: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||
impersonations: sql`CAST(SUM(CASE WHEN action_type LIKE 'impersonate%' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
}).from(adminActions),
|
||||
]);
|
||||
|
||||
return {
|
||||
users: userStats[0],
|
||||
qsos: qsoStats[0],
|
||||
syncJobs: syncJobStats[0],
|
||||
adminActions: adminStats[0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-user statistics (for admin overview)
|
||||
* @returns {Promise<Array>} Array of user statistics
|
||||
*/
|
||||
export async function getUserStats() {
|
||||
const stats = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
callsign: users.callsign,
|
||||
role: users.role,
|
||||
isAdmin: users.isAdmin,
|
||||
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
|
||||
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||
lastSync: sql`MAX(${qsos.createdAt})`,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(qsos, eq(users.id, qsos.userId))
|
||||
.groupBy(users.id)
|
||||
.orderBy(sql`COUNT(${qsos.id}) DESC`);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Impersonate a user
|
||||
* @param {number} adminId - Admin user ID
|
||||
* @param {number} targetUserId - Target user ID to impersonate
|
||||
* @returns {Promise<Object>} Target user object
|
||||
* @throws {Error} If not admin or trying to impersonate another admin
|
||||
*/
|
||||
export async function impersonateUser(adminId, targetUserId) {
|
||||
// Verify the requester is an admin
|
||||
const requesterIsAdmin = await isAdmin(adminId);
|
||||
if (!requesterIsAdmin) {
|
||||
throw new Error('Only admins can impersonate users');
|
||||
}
|
||||
|
||||
// Get target user
|
||||
const targetUser = await getUserByIdFull(targetUserId);
|
||||
if (!targetUser) {
|
||||
throw new Error('Target user not found');
|
||||
}
|
||||
|
||||
// Check if target is also an admin (prevent admin impersonation)
|
||||
if (targetUser.isAdmin) {
|
||||
throw new Error('Cannot impersonate another admin user');
|
||||
}
|
||||
|
||||
// Log impersonation action
|
||||
await logAdminAction(adminId, 'impersonate_start', targetUserId, {
|
||||
targetEmail: targetUser.email,
|
||||
targetCallsign: targetUser.callsign,
|
||||
});
|
||||
|
||||
return targetUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify impersonation token is valid
|
||||
* @param {Object} impersonationToken - JWT token payload containing impersonation data
|
||||
* @returns {Promise<Object>} Verification result with target user data
|
||||
*/
|
||||
export async function verifyImpersonation(impersonationToken) {
|
||||
const { adminId, targetUserId, exp } = impersonationToken;
|
||||
|
||||
// Check if token is expired
|
||||
if (Date.now() > exp * 1000) {
|
||||
throw new Error('Impersonation token has expired');
|
||||
}
|
||||
|
||||
// Verify admin still exists and is admin
|
||||
const adminUser = await getUserByIdFull(adminId);
|
||||
if (!adminUser || !adminUser.isAdmin) {
|
||||
throw new Error('Invalid impersonation: Admin no longer exists or is not admin');
|
||||
}
|
||||
|
||||
// Get target user
|
||||
const targetUser = await getUserByIdFull(targetUserId);
|
||||
if (!targetUser) {
|
||||
throw new Error('Target user not found');
|
||||
}
|
||||
|
||||
// Return target user with admin metadata for frontend display
|
||||
return {
|
||||
...targetUser,
|
||||
impersonating: {
|
||||
adminId,
|
||||
adminEmail: adminUser.email,
|
||||
adminCallsign: adminUser.callsign,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop impersonating a user
|
||||
* @param {number} adminId - Admin user ID
|
||||
* @param {number} targetUserId - Target user ID being impersonated
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function stopImpersonation(adminId, targetUserId) {
|
||||
await logAdminAction(adminId, 'impersonate_stop', targetUserId, {
|
||||
message: 'Impersonation session ended',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get impersonation status for an admin
|
||||
* @param {number} adminId - Admin user ID
|
||||
* @param {Object} options - Query options
|
||||
* @param {number} options.limit - Number of recent impersonations to return
|
||||
* @returns {Promise<Array>} Array of recent impersonation actions
|
||||
*/
|
||||
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
||||
const impersonations = await db
|
||||
.select({
|
||||
id: adminActions.id,
|
||||
actionType: adminActions.actionType,
|
||||
targetUserId: adminActions.targetUserId,
|
||||
targetEmail: sql`target_users.email`,
|
||||
targetCallsign: sql`target_users.callsign`,
|
||||
details: adminActions.details,
|
||||
createdAt: adminActions.createdAt,
|
||||
})
|
||||
.from(adminActions)
|
||||
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
||||
.where(eq(adminActions.adminId, adminId))
|
||||
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`)
|
||||
.orderBy(desc(adminActions.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return impersonations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user role (admin operation)
|
||||
* @param {number} adminId - Admin user ID making the change
|
||||
* @param {number} targetUserId - User ID to update
|
||||
* @param {string} newRole - New role ('user' or 'admin')
|
||||
* @param {boolean} newIsAdmin - New admin flag
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If not admin or would remove last admin
|
||||
*/
|
||||
export async function changeUserRole(adminId, targetUserId, newRole, newIsAdmin) {
|
||||
// Verify the requester is an admin
|
||||
const requesterIsAdmin = await isAdmin(adminId);
|
||||
if (!requesterIsAdmin) {
|
||||
throw new Error('Only admins can change user roles');
|
||||
}
|
||||
|
||||
// Get target user
|
||||
const targetUser = await getUserByIdFull(targetUserId);
|
||||
if (!targetUser) {
|
||||
throw new Error('Target user not found');
|
||||
}
|
||||
|
||||
// If demoting from admin, check if this would remove the last admin
|
||||
if (targetUser.isAdmin && !newIsAdmin) {
|
||||
const adminCount = await db
|
||||
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||
.from(users)
|
||||
.where(eq(users.isAdmin, 1));
|
||||
|
||||
if (adminCount[0].count === 1) {
|
||||
throw new Error('Cannot demote the last admin user');
|
||||
}
|
||||
}
|
||||
|
||||
// Update role
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
role: newRole,
|
||||
isAdmin: newIsAdmin ? 1 : 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, targetUserId));
|
||||
|
||||
// Log action
|
||||
await logAdminAction(adminId, 'role_change', targetUserId, {
|
||||
oldRole: targetUser.role,
|
||||
newRole: newRole,
|
||||
oldIsAdmin: targetUser.isAdmin,
|
||||
newIsAdmin: newIsAdmin,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user (admin operation)
|
||||
* @param {number} adminId - Admin user ID making the change
|
||||
* @param {number} targetUserId - User ID to delete
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If not admin, trying to delete self, or trying to delete another admin
|
||||
*/
|
||||
export async function deleteUser(adminId, targetUserId) {
|
||||
// Verify the requester is an admin
|
||||
const requesterIsAdmin = await isAdmin(adminId);
|
||||
if (!requesterIsAdmin) {
|
||||
throw new Error('Only admins can delete users');
|
||||
}
|
||||
|
||||
// Get target user
|
||||
const targetUser = await getUserByIdFull(targetUserId);
|
||||
if (!targetUser) {
|
||||
throw new Error('Target user not found');
|
||||
}
|
||||
|
||||
// Prevent deleting self
|
||||
if (adminId === targetUserId) {
|
||||
throw new Error('Cannot delete your own account');
|
||||
}
|
||||
|
||||
// Prevent deleting other admins
|
||||
if (targetUser.isAdmin) {
|
||||
throw new Error('Cannot delete admin users');
|
||||
}
|
||||
|
||||
// Get stats for logging
|
||||
const [qsoStats] = await db
|
||||
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||
.from(qsos)
|
||||
.where(eq(qsos.userId, targetUserId));
|
||||
|
||||
// Delete all related records using Drizzle
|
||||
// Delete in correct order to satisfy foreign key constraints
|
||||
logger.info('Attempting to delete user', { userId: targetUserId, adminId });
|
||||
|
||||
try {
|
||||
// 1. Delete qso_changes (references qso_id -> qsos and job_id -> sync_jobs)
|
||||
// First get user's QSO IDs, then delete qso_changes referencing those QSOs
|
||||
const userQSOs = await db.select({ id: qsos.id }).from(qsos).where(eq(qsos.userId, targetUserId));
|
||||
const userQSOIds = userQSOs.map(q => q.id);
|
||||
|
||||
if (userQSOIds.length > 0) {
|
||||
// Use raw SQL to delete qso_changes
|
||||
sqlite.exec(
|
||||
`DELETE FROM qso_changes WHERE qso_id IN (${userQSOIds.join(',')})`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Delete award_progress
|
||||
await db.delete(awardProgress).where(eq(awardProgress.userId, targetUserId));
|
||||
|
||||
// 3. Delete sync_jobs
|
||||
await db.delete(syncJobs).where(eq(syncJobs.userId, targetUserId));
|
||||
|
||||
// 4. Delete qsos
|
||||
await db.delete(qsos).where(eq(qsos.userId, targetUserId));
|
||||
|
||||
// 5. Delete admin actions where user is target
|
||||
await db.delete(adminActions).where(eq(adminActions.targetUserId, targetUserId));
|
||||
|
||||
// 6. Delete user
|
||||
await db.delete(users).where(eq(users.id, targetUserId));
|
||||
|
||||
// Log action
|
||||
await logAdminAction(adminId, 'user_delete', targetUserId, {
|
||||
email: targetUser.email,
|
||||
callsign: targetUser.callsign,
|
||||
qsoCountDeleted: qsoStats.count,
|
||||
});
|
||||
|
||||
logger.info('User deleted successfully', { userId: targetUserId, adminId });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete user', { error: error.message, userId: targetUserId });
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log action
|
||||
await logAdminAction(adminId, 'user_delete', targetUserId, {
|
||||
email: targetUser.email,
|
||||
callsign: targetUser.callsign,
|
||||
qsoCountDeleted: qsoStats.count,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user