Files
award/src/backend/services/admin.service.js
Joerg bdd8aa497d fix: admin action log and impersonation improvements
- Fix admin action log not displaying entries (use raw sqlite for self-join)
- Add global impersonation banner to all pages during impersonation
- Fix timestamp display in action log (convert Unix seconds to milliseconds)
- Add loginWithToken method to auth store for direct token authentication
- Fix /api/auth/me to include impersonatedBy field from JWT
- Remove duplicate impersonation code from admin page

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:26:20 +01:00

403 lines
13 KiB
JavaScript

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 } = {}) {
// Use raw SQL for the self-join (admin users and target users from same users table)
// Using bun:sqlite prepared statements for raw SQL
let query = `
SELECT
aa.id as id,
aa.admin_id as adminId,
admin_user.email as adminEmail,
admin_user.callsign as adminCallsign,
aa.action_type as actionType,
aa.target_user_id as targetUserId,
target_user.email as targetEmail,
target_user.callsign as targetCallsign,
aa.details as details,
aa.created_at as createdAt
FROM admin_actions aa
LEFT JOIN users admin_user ON admin_user.id = aa.admin_id
LEFT JOIN users target_user ON target_user.id = aa.target_user_id
`;
const params = [];
if (adminId !== null) {
query += ` WHERE aa.admin_id = ?`;
params.push(adminId);
}
query += ` ORDER BY aa.created_at DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
return sqlite.prepare(query).all(...params);
}
/**
* 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,
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`(
SELECT MAX(${syncJobs.completedAt})
FROM ${syncJobs}
WHERE ${syncJobs.userId} = ${users.id}
AND ${syncJobs.status} = 'completed'
)`.mapWith(Number),
createdAt: users.createdAt,
})
.from(users)
.leftJoin(qsos, eq(users.id, qsos.userId))
.groupBy(users.id)
.orderBy(sql`COUNT(${qsos.id}) DESC`);
// Convert lastSync timestamps (seconds) to Date objects for JSON serialization
return stats.map(stat => ({
...stat,
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
}));
}
/**
* 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 } = {}) {
// Use raw SQL for the self-join to avoid Drizzle alias issues
// Using bun:sqlite prepared statements for raw SQL
const query = `
SELECT
aa.id as id,
aa.action_type as actionType,
aa.target_user_id as targetUserId,
u.email as targetEmail,
u.callsign as targetCallsign,
aa.details as details,
aa.created_at as createdAt
FROM admin_actions aa
LEFT JOIN users u ON u.id = aa.target_user_id
WHERE aa.admin_id = ?
AND aa.action_type LIKE 'impersonate%'
ORDER BY aa.created_at DESC
LIMIT ?
`;
return sqlite.prepare(query).all(adminId, limit);
}
/**
* Update user admin status (admin operation)
* @param {number} adminId - Admin user ID making the change
* @param {number} targetUserId - User ID to update
* @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, newIsAdmin) {
// Verify the requester is an admin
const requesterIsAdmin = await isAdmin(adminId);
if (!requesterIsAdmin) {
throw new Error('Only admins can change user admin status');
}
// 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 admin status
await db
.update(users)
.set({
isAdmin: newIsAdmin ? 1 : 0,
updatedAt: new Date(),
})
.where(eq(users.id, targetUserId));
// Log action
await logAdminAction(adminId, 'role_change', targetUserId, {
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,
});
}