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

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