diff --git a/src/backend/config.js b/src/backend/config.js index 22cb4b2..de82602 100644 --- a/src/backend/config.js +++ b/src/backend/config.js @@ -122,6 +122,8 @@ export const db = drizzle({ schema, }); +export { sqlite }; + export async function closeDatabase() { sqlite.close(); } diff --git a/src/backend/db/schema/index.js b/src/backend/db/schema/index.js index b86701b..e79a798 100644 --- a/src/backend/db/schema/index.js +++ b/src/backend/db/schema/index.js @@ -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 }; diff --git a/src/backend/index.js b/src/backend/index.js index 6be1027..8f079cf 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -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); diff --git a/src/backend/migrations/add-admin-functionality.js b/src/backend/migrations/add-admin-functionality.js new file mode 100644 index 0000000..885594c --- /dev/null +++ b/src/backend/migrations/add-admin-functionality.js @@ -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); +}); diff --git a/src/backend/scripts/admin-cli.js b/src/backend/scripts/admin-cli.js new file mode 100644 index 0000000..bca949a --- /dev/null +++ b/src/backend/scripts/admin-cli.js @@ -0,0 +1,253 @@ +#!/usr/bin/env bun +/** + * Admin CLI Tool + * + * Usage: + * bun src/backend/scripts/admin-cli.js create + * bun src/backend/scripts/admin-cli.js promote + * bun src/backend/scripts/admin-cli.js demote + * bun src/backend/scripts/admin-cli.js list + * bun src/backend/scripts/admin-cli.js check + * 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 Create a new admin user + promote Promote existing user to admin + demote Demote admin to regular user + list List all admin users + check 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: '); + 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: '); + help(); + process.exit(1); + } + promoteUser(args[0]); + break; + + case 'demote': + if (args.length !== 1) { + console.error('Error: demote command requires 1 argument: '); + 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: '); + 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(); diff --git a/src/backend/services/admin.service.js b/src/backend/services/admin.service.js new file mode 100644 index 0000000..6d31bef --- /dev/null +++ b/src/backend/services/admin.service.js @@ -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} 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 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} 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 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} 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} 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} + */ +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 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} + * @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} + * @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, + }); +} diff --git a/src/backend/services/auth.service.js b/src/backend/services/auth.service.js index 51b8a28..876b67d 100644 --- a/src/backend/services/auth.service.js +++ b/src/backend/services/auth.service.js @@ -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} 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 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} + */ +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 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} 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; +} diff --git a/src/frontend/src/lib/api.js b/src/frontend/src/lib/api.js index f4ccfef..1a6e016 100644 --- a/src/frontend/src/lib/api.js +++ b/src/frontend/src/lib/api.js @@ -86,3 +86,35 @@ export const jobsAPI = { getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`), cancel: (jobId) => apiRequest(`/jobs/${jobId}`, { method: 'DELETE' }), }; + +// Admin API +export const adminAPI = { + getStats: () => apiRequest('/admin/stats'), + + getUsers: () => apiRequest('/admin/users'), + + getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`), + + updateUserRole: (userId, role, isAdmin) => apiRequest(`/admin/users/${userId}/role`, { + method: 'POST', + body: JSON.stringify({ role, isAdmin }), + }), + + deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, { + method: 'DELETE', + }), + + impersonate: (userId) => apiRequest(`/admin/impersonate/${userId}`, { + method: 'POST', + }), + + stopImpersonation: () => apiRequest('/admin/impersonate/stop', { + method: 'POST', + }), + + getImpersonationStatus: () => apiRequest('/admin/impersonation/status'), + + getActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions?limit=${limit}&offset=${offset}`), + + getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`), +}; diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte index adc6173..21fc742 100644 --- a/src/frontend/src/routes/+layout.svelte +++ b/src/frontend/src/routes/+layout.svelte @@ -27,6 +27,9 @@ Awards QSOs Settings + {#if $auth.user?.isAdmin} + Admin + {/if} @@ -119,6 +122,16 @@ background-color: rgba(255, 107, 107, 0.1); } + .admin-link { + background-color: #ffc107; + color: #000; + font-weight: 600; + } + + .admin-link:hover { + background-color: #e0a800; + } + main { flex: 1; padding: 2rem 1rem; diff --git a/src/frontend/src/routes/admin/+page.svelte b/src/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..c64ddcf --- /dev/null +++ b/src/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,1016 @@ + + +{#if loading && !systemStats} +
Loading admin dashboard...
+{:else if error} +
{error}
+{:else} +
+ + {#if $auth.user?.impersonatedBy} +
+
+ ⚠️ + + You are currently impersonating {$auth.user.email} + + +
+
+ {/if} + +

Admin Dashboard

+ + +
+ + + +
+ + + {#if selectedTab === 'overview'} +
+

System Statistics

+ + {#if systemStats} +
+ +
+

Users

+
{systemStats.users?.totalUsers || 0}
+
+ Admin: {systemStats.users?.adminUsers || 0} + Regular: {systemStats.users?.regularUsers || 0} +
+
+ + +
+

QSOs

+
{systemStats.qsos?.totalQSOs || 0}
+
+ Unique Callsigns: {systemStats.qsos?.uniqueCallsigns || 0} + Unique Entities: {systemStats.qsos?.uniqueEntities || 0} +
+
+ + +
+

Confirmations

+
+ LoTW: {systemStats.qsos?.lotwConfirmed || 0} + DCL: {systemStats.qsos?.dclConfirmed || 0} +
+
+ + +
+

Sync Jobs

+
{systemStats.syncJobs?.totalJobs || 0}
+
+ LoTW: {systemStats.syncJobs?.lotwJobs || 0} + DCL: {systemStats.syncJobs?.dclJobs || 0} + Completed: {systemStats.syncJobs?.completedJobs || 0} + Failed: {systemStats.syncJobs?.failedJobs || 0} +
+
+ + +
+

Admin Actions

+
{systemStats.adminActions?.totalAdminActions || 0}
+
+ Impersonations: {systemStats.adminActions?.impersonations || 0} +
+
+
+ {:else} +
Failed to load system statistics
+ {/if} +
+ {/if} + + + {#if selectedTab === 'users'} +
+
+

User Management

+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + {#each filteredUsers as user} + + + + + + + + + + + + + {/each} + +
IDEmailCallsignRoleQSOsLoTW Conf.DCL Conf.Total Conf.Last SyncActions
{user.id}{user.email}{user.callsign} + + {user.isAdmin ? 'Admin' : 'User'} + + {user.qsoCount || 0}{user.lotwConfirmed || 0}{user.dclConfirmed || 0}{user.totalConfirmed || 0}{formatDate(user.lastSync)} + + + +
+
+ +

Showing {filteredUsers.length} user(s)

+
+ {/if} + + + {#if selectedTab === 'actions'} +
+

Admin Action Log

+ + {#if adminActions.length > 0} +
+ + + + + + + + + + + {#each adminActions as action} + + + + + + + {/each} + +
TimestampAction TypeTarget UserDetails
{formatDate(action.createdAt)} + + {action.actionType} + + + {#if action.targetEmail} + {action.targetEmail} ({action.targetCallsign}) + {:else} + N/A + {/if} + + {#if action.details} +
{JSON.stringify(JSON.parse(action.details), null, 2)}
+ {:else} + N/A + {/if} +
+
+ {:else} +
No admin actions recorded
+ {/if} +
+ {/if} +
+{/if} + + +{#if showImpersonationModal && selectedUser} + +{/if} + + +{#if showRoleChangeModal && selectedUser} + +{/if} + + +{#if showDeleteUserModal && selectedUser} + +{/if} + +