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

@@ -122,6 +122,8 @@ export const db = drizzle({
schema,
});
export { sqlite };
export async function closeDatabase() {
sqlite.close();
}

View File

@@ -9,6 +9,8 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
* @property {string|null} lotwUsername
* @property {string|null} lotwPassword
* @property {string|null} dclApiKey
* @property {string} role
* @property {boolean} isAdmin
* @property {Date} createdAt
* @property {Date} updatedAt
*/
@@ -21,6 +23,8 @@ export const users = sqliteTable('users', {
lotwUsername: text('lotw_username'),
lotwPassword: text('lotw_password'), // Encrypted
dclApiKey: text('dcl_api_key'), // DCL API key for future use
role: text('role').notNull().default('user'), // 'user', 'admin'
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Simplified admin check
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
@@ -202,5 +206,24 @@ export const qsoChanges = sqliteTable('qso_changes', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
/**
* @typedef {Object} AdminAction
* @property {number} id
* @property {number} adminId
* @property {string} actionType
* @property {number|null} targetUserId
* @property {string|null} details
* @property {Date} createdAt
*/
export const adminActions = sqliteTable('admin_actions', {
id: integer('id').primaryKey({ autoIncrement: true }),
adminId: integer('admin_id').notNull().references(() => users.id),
actionType: text('action_type').notNull(), // 'impersonate_start', 'impersonate_stop', 'role_change', 'user_delete', etc.
targetUserId: integer('target_user_id').references(() => users.id),
details: text('details'), // JSON with additional context
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
// Export all schemas
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges };
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions };

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

View File

@@ -0,0 +1,103 @@
/**
* Migration: Add admin functionality to users table and create admin_actions table
*
* This script adds role-based access control (RBAC) for admin functionality:
* - Adds 'role' and 'isAdmin' columns to users table
* - Creates admin_actions table for audit logging
* - Adds indexes for performance
*/
import Database from 'bun:sqlite';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../award.db');
const sqlite = new Database(dbPath);
async function migrate() {
console.log('Starting migration: Add admin functionality...');
try {
// Check if role column already exists in users table
const columnExists = sqlite.query(`
SELECT COUNT(*) as count
FROM pragma_table_info('users')
WHERE name = 'role'
`).get();
if (columnExists.count > 0) {
console.log('Admin columns already exist in users table. Skipping...');
} else {
// Add role column to users table
sqlite.exec(`
ALTER TABLE users
ADD COLUMN role TEXT NOT NULL DEFAULT 'user'
`);
// Add isAdmin column to users table
sqlite.exec(`
ALTER TABLE users
ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0
`);
console.log('Added role and isAdmin columns to users table');
}
// Check if admin_actions table already exists
const tableExists = sqlite.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='admin_actions'
`).get();
if (tableExists) {
console.log('Table admin_actions already exists. Skipping...');
} else {
// Create admin_actions table
sqlite.exec(`
CREATE TABLE admin_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL,
action_type TEXT NOT NULL,
target_user_id INTEGER,
details TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
FOREIGN KEY (admin_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL
)
`);
// Create indexes for admin_actions
sqlite.exec(`
CREATE INDEX idx_admin_actions_admin_id ON admin_actions(admin_id)
`);
sqlite.exec(`
CREATE INDEX idx_admin_actions_action_type ON admin_actions(action_type)
`);
sqlite.exec(`
CREATE INDEX idx_admin_actions_created_at ON admin_actions(created_at)
`);
console.log('Created admin_actions table with indexes');
}
console.log('Migration complete! Admin functionality added to database.');
} catch (error) {
console.error('Migration failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
// Run migration
migrate().then(() => {
console.log('Migration script completed successfully');
process.exit(0);
});

View File

@@ -0,0 +1,253 @@
#!/usr/bin/env bun
/**
* Admin CLI Tool
*
* Usage:
* bun src/backend/scripts/admin-cli.js create <email> <password> <callsign>
* bun src/backend/scripts/admin-cli.js promote <email>
* bun src/backend/scripts/admin-cli.js demote <email>
* bun src/backend/scripts/admin-cli.js list
* bun src/backend/scripts/admin-cli.js check <email>
* bun src/backend/scripts/admin-cli.js help
*/
import Database from 'bun:sqlite';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../award.db');
const sqlite = new Database(dbPath);
// Enable foreign keys
sqlite.exec('PRAGMA foreign_keys = ON');
function help() {
console.log(`
Admin CLI Tool - Manage admin users
Commands:
create <email> <password> <callsign> Create a new admin user
promote <email> Promote existing user to admin
demote <email> Demote admin to regular user
list List all admin users
check <email> Check if user is admin
help Show this help message
Examples:
bun src/backend/scripts/admin-cli.js create admin@example.com secretPassword ADMIN
bun src/backend/scripts/admin-cli.js promote user@example.com
bun src/backend/scripts/admin-cli.js list
bun src/backend/scripts/admin-cli.js check user@example.com
`);
}
function createAdminUser(email, password, callsign) {
console.log(`Creating admin user: ${email}`);
// Check if user already exists
const existingUser = sqlite.query(`
SELECT id, email FROM users WHERE email = ?
`).get(email);
if (existingUser) {
console.error(`Error: User with email ${email} already exists`);
process.exit(1);
}
// Hash password
const passwordHash = Bun.password.hashSync(password, {
algorithm: 'bcrypt',
cost: 10,
});
// Ensure passwordHash is a string
const hashString = String(passwordHash);
// Insert admin user
const result = sqlite.query(`
INSERT INTO users (email, password_hash, callsign, role, is_admin, created_at, updated_at)
VALUES (?, ?, ?, 'admin', 1, strftime('%s', 'now') * 1000, strftime('%s', 'now') * 1000)
`).run(email, hashString, callsign);
console.log(`✓ Admin user created successfully!`);
console.log(` ID: ${result.lastInsertRowid}`);
console.log(` Email: ${email}`);
console.log(` Callsign: ${callsign}`);
console.log(` Role: admin`);
console.log(`\nYou can now log in with these credentials.`);
}
function promoteUser(email) {
console.log(`Promoting user to admin: ${email}`);
// Check if user exists
const user = sqlite.query(`
SELECT id, email, role, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
console.error(`Error: User with email ${email} not found`);
process.exit(1);
}
if (user.role === 'admin' && user.is_admin === 1) {
console.log(`User ${email} is already an admin`);
return;
}
// Update user to admin
sqlite.query(`
UPDATE users
SET role = 'admin', is_admin = 1, updated_at = strftime('%s', 'now') * 1000
WHERE email = ?
`).run(email);
console.log(`✓ User ${email} has been promoted to admin`);
}
function demoteUser(email) {
console.log(`Demoting admin to regular user: ${email}`);
// Check if user exists
const user = sqlite.query(`
SELECT id, email, role, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
console.error(`Error: User with email ${email} not found`);
process.exit(1);
}
if (user.role !== 'admin' || user.is_admin !== 1) {
console.log(`User ${email} is not an admin`);
return;
}
// Check if this is the last admin
const adminCount = sqlite.query(`
SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND is_admin = 1
`).get();
if (adminCount.count === 1) {
console.error(`Error: Cannot demote the last admin user. At least one admin must exist.`);
process.exit(1);
}
// Update user to regular user
sqlite.query(`
UPDATE users
SET role = 'user', is_admin = 0, updated_at = strftime('%s', 'now') * 1000
WHERE email = ?
`).run(email);
console.log(`✓ User ${email} has been demoted to regular user`);
}
function listAdmins() {
console.log('Listing all admin users...\n');
const admins = sqlite.query(`
SELECT id, email, callsign, created_at
FROM users
WHERE role = 'admin' AND is_admin = 1
ORDER BY created_at ASC
`).all();
if (admins.length === 0) {
console.log('No admin users found');
return;
}
console.log(`Found ${admins.length} admin user(s):\n`);
console.log('ID | Email | Callsign | Created At');
console.log('----+----------------------------+----------+---------------------');
admins.forEach((admin) => {
const createdAt = new Date(admin.created_at).toLocaleString();
console.log(`${String(admin.id).padEnd(3)} | ${admin.email.padEnd(26)} | ${admin.callsign.padEnd(8)} | ${createdAt}`);
});
}
function checkUser(email) {
console.log(`Checking user status: ${email}\n`);
const user = sqlite.query(`
SELECT id, email, callsign, role, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
console.log(`User not found: ${email}`);
process.exit(1);
}
const isAdmin = user.role === 'admin' && user.is_admin === 1;
console.log(`User found:`);
console.log(` Email: ${user.email}`);
console.log(` Callsign: ${user.callsign}`);
console.log(` Role: ${user.role}`);
console.log(` Is Admin: ${isAdmin ? 'Yes ✓' : 'No'}`);
}
// Main CLI logic
const command = process.argv[2];
const args = process.argv.slice(3);
switch (command) {
case 'create':
if (args.length !== 3) {
console.error('Error: create command requires 3 arguments: <email> <password> <callsign>');
help();
process.exit(1);
}
createAdminUser(args[0], args[1], args[2]);
break;
case 'promote':
if (args.length !== 1) {
console.error('Error: promote command requires 1 argument: <email>');
help();
process.exit(1);
}
promoteUser(args[0]);
break;
case 'demote':
if (args.length !== 1) {
console.error('Error: demote command requires 1 argument: <email>');
help();
process.exit(1);
}
demoteUser(args[0]);
break;
case 'list':
listAdmins();
break;
case 'check':
if (args.length !== 1) {
console.error('Error: check command requires 1 argument: <email>');
help();
process.exit(1);
}
checkUser(args[0]);
break;
case 'help':
case '--help':
case '-h':
help();
break;
default:
console.error(`Error: Unknown command '${command}'`);
help();
process.exit(1);
}
sqlite.close();

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

View File

@@ -142,3 +142,102 @@ export async function updateDCLCredentials(userId, dclApiKey) {
})
.where(eq(users.id, userId));
}
/**
* Check if user is admin
* @param {number} userId - User ID
* @returns {Promise<boolean>} True if user is admin
*/
export async function isAdmin(userId) {
const [user] = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user?.isAdmin === true || user?.isAdmin === 1;
}
/**
* Get all admin users
* @returns {Promise<Array>} Array of admin users (without passwords)
*/
export async function getAdminUsers() {
const adminUsers = await db
.select({
id: users.id,
email: users.email,
callsign: users.callsign,
role: users.role,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.isAdmin, 1));
return adminUsers;
}
/**
* Update user role
* @param {number} userId - User ID
* @param {string} role - New role ('user' or 'admin')
* @param {boolean} isAdmin - Admin flag
* @returns {Promise<void>}
*/
export async function updateUserRole(userId, role, isAdmin) {
await db
.update(users)
.set({
role,
isAdmin: isAdmin ? 1 : 0,
updatedAt: new Date(),
})
.where(eq(users.id, userId));
}
/**
* Get all users (for admin use)
* @returns {Promise<Array>} Array of all users (without passwords)
*/
export async function getAllUsers() {
const allUsers = await db
.select({
id: users.id,
email: users.email,
callsign: users.callsign,
role: users.role,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.orderBy(users.createdAt);
return allUsers;
}
/**
* Get user by ID (for admin use)
* @param {number} userId - User ID
* @returns {Promise<Object|null>} Full user object (without password) or null
*/
export async function getUserByIdFull(userId) {
const [user] = await db
.select({
id: users.id,
email: users.email,
callsign: users.callsign,
role: users.role,
isAdmin: users.isAdmin,
lotwUsername: users.lotwUsername,
dclApiKey: users.dclApiKey,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user || null;
}