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:
@@ -122,6 +122,8 @@ export const db = drizzle({
|
|||||||
schema,
|
schema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { sqlite };
|
||||||
|
|
||||||
export async function closeDatabase() {
|
export async function closeDatabase() {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|||||||
* @property {string|null} lotwUsername
|
* @property {string|null} lotwUsername
|
||||||
* @property {string|null} lotwPassword
|
* @property {string|null} lotwPassword
|
||||||
* @property {string|null} dclApiKey
|
* @property {string|null} dclApiKey
|
||||||
|
* @property {string} role
|
||||||
|
* @property {boolean} isAdmin
|
||||||
* @property {Date} createdAt
|
* @property {Date} createdAt
|
||||||
* @property {Date} updatedAt
|
* @property {Date} updatedAt
|
||||||
*/
|
*/
|
||||||
@@ -21,6 +23,8 @@ export const users = sqliteTable('users', {
|
|||||||
lotwUsername: text('lotw_username'),
|
lotwUsername: text('lotw_username'),
|
||||||
lotwPassword: text('lotw_password'), // Encrypted
|
lotwPassword: text('lotw_password'), // Encrypted
|
||||||
dclApiKey: text('dcl_api_key'), // DCL API key for future use
|
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()),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
updatedAt: integer('updated_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()),
|
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 all schemas
|
||||||
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges };
|
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions };
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ import {
|
|||||||
updateLoTWCredentials,
|
updateLoTWCredentials,
|
||||||
updateDCLCredentials,
|
updateDCLCredentials,
|
||||||
} from './services/auth.service.js';
|
} 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 {
|
import {
|
||||||
getUserQSOs,
|
getUserQSOs,
|
||||||
getQSOStats,
|
getQSOStats,
|
||||||
@@ -176,12 +187,19 @@ const app = new Elysia()
|
|||||||
return { user: null };
|
return { user: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is an impersonation token
|
||||||
|
const isImpersonation = !!payload.impersonatedBy;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: payload.userId,
|
id: payload.userId,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
callsign: payload.callsign,
|
callsign: payload.callsign,
|
||||||
|
role: payload.role,
|
||||||
|
isAdmin: payload.isAdmin,
|
||||||
|
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
|
||||||
},
|
},
|
||||||
|
isImpersonation,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { user: null };
|
return { user: null };
|
||||||
@@ -382,6 +400,8 @@ const app = new Elysia()
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
|
role: user.role,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
exp,
|
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
|
// Serve static files and SPA fallback for all non-API routes
|
||||||
.get('/*', ({ request }) => {
|
.get('/*', ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|||||||
103
src/backend/migrations/add-admin-functionality.js
Normal file
103
src/backend/migrations/add-admin-functionality.js
Normal 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);
|
||||||
|
});
|
||||||
253
src/backend/scripts/admin-cli.js
Normal file
253
src/backend/scripts/admin-cli.js
Normal 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();
|
||||||
392
src/backend/services/admin.service.js
Normal file
392
src/backend/services/admin.service.js
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { eq, sql, desc } from 'drizzle-orm';
|
||||||
|
import { db, sqlite, logger } from '../config.js';
|
||||||
|
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
|
||||||
|
import { getUserByIdFull, isAdmin } from './auth.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an admin action for audit trail
|
||||||
|
* @param {number} adminId - Admin user ID
|
||||||
|
* @param {string} actionType - Type of action (e.g., 'impersonate_start', 'role_change')
|
||||||
|
* @param {number|null} targetUserId - Target user ID (if applicable)
|
||||||
|
* @param {Object} details - Additional details (will be JSON stringified)
|
||||||
|
* @returns {Promise<Object>} Created admin action record
|
||||||
|
*/
|
||||||
|
export async function logAdminAction(adminId, actionType, targetUserId = null, details = {}) {
|
||||||
|
const [action] = await db
|
||||||
|
.insert(adminActions)
|
||||||
|
.values({
|
||||||
|
adminId,
|
||||||
|
actionType,
|
||||||
|
targetUserId,
|
||||||
|
details: JSON.stringify(details),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get admin actions log
|
||||||
|
* @param {number} adminId - Admin user ID (optional, if null returns all actions)
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {number} options.limit - Number of records to return
|
||||||
|
* @param {number} options.offset - Number of records to skip
|
||||||
|
* @returns {Promise<Array>} Array of admin actions
|
||||||
|
*/
|
||||||
|
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
|
||||||
|
let query = db
|
||||||
|
.select({
|
||||||
|
id: adminActions.id,
|
||||||
|
adminId: adminActions.adminId,
|
||||||
|
adminEmail: users.email,
|
||||||
|
adminCallsign: users.callsign,
|
||||||
|
actionType: adminActions.actionType,
|
||||||
|
targetUserId: adminActions.targetUserId,
|
||||||
|
targetEmail: sql`target_users.email`.as('targetEmail'),
|
||||||
|
targetCallsign: sql`target_users.callsign`.as('targetCallsign'),
|
||||||
|
details: adminActions.details,
|
||||||
|
createdAt: adminActions.createdAt,
|
||||||
|
})
|
||||||
|
.from(adminActions)
|
||||||
|
.leftJoin(users, eq(adminActions.adminId, users.id))
|
||||||
|
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
||||||
|
.orderBy(desc(adminActions.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
if (adminId) {
|
||||||
|
query = query.where(eq(adminActions.adminId, adminId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system-wide statistics
|
||||||
|
* @returns {Promise<Object>} System statistics
|
||||||
|
*/
|
||||||
|
export async function getSystemStats() {
|
||||||
|
const [
|
||||||
|
userStats,
|
||||||
|
qsoStats,
|
||||||
|
syncJobStats,
|
||||||
|
adminStats,
|
||||||
|
] = await Promise.all([
|
||||||
|
// User statistics
|
||||||
|
db.select({
|
||||||
|
totalUsers: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
adminUsers: sql`CAST(SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
regularUsers: sql`CAST(SUM(CASE WHEN is_admin = 0 THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
}).from(users),
|
||||||
|
|
||||||
|
// QSO statistics
|
||||||
|
db.select({
|
||||||
|
totalQSOs: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
uniqueCallsigns: sql`CAST(COUNT(DISTINCT callsign) AS INTEGER)`,
|
||||||
|
uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
|
||||||
|
lotwConfirmed: sql`CAST(SUM(CASE WHEN lotw_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
dclConfirmed: sql`CAST(SUM(CASE WHEN dcl_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
}).from(qsos),
|
||||||
|
|
||||||
|
// Sync job statistics
|
||||||
|
db.select({
|
||||||
|
totalJobs: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
lotwJobs: sql`CAST(SUM(CASE WHEN type = 'lotw_sync' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
dclJobs: sql`CAST(SUM(CASE WHEN type = 'dcl_sync' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
completedJobs: sql`CAST(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
failedJobs: sql`CAST(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
}).from(syncJobs),
|
||||||
|
|
||||||
|
// Admin action statistics
|
||||||
|
db.select({
|
||||||
|
totalAdminActions: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
impersonations: sql`CAST(SUM(CASE WHEN action_type LIKE 'impersonate%' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
}).from(adminActions),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: userStats[0],
|
||||||
|
qsos: qsoStats[0],
|
||||||
|
syncJobs: syncJobStats[0],
|
||||||
|
adminActions: adminStats[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get per-user statistics (for admin overview)
|
||||||
|
* @returns {Promise<Array>} Array of user statistics
|
||||||
|
*/
|
||||||
|
export async function getUserStats() {
|
||||||
|
const stats = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
callsign: users.callsign,
|
||||||
|
role: users.role,
|
||||||
|
isAdmin: users.isAdmin,
|
||||||
|
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
|
||||||
|
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
lastSync: sql`MAX(${qsos.createdAt})`,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(qsos, eq(users.id, qsos.userId))
|
||||||
|
.groupBy(users.id)
|
||||||
|
.orderBy(sql`COUNT(${qsos.id}) DESC`);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impersonate a user
|
||||||
|
* @param {number} adminId - Admin user ID
|
||||||
|
* @param {number} targetUserId - Target user ID to impersonate
|
||||||
|
* @returns {Promise<Object>} Target user object
|
||||||
|
* @throws {Error} If not admin or trying to impersonate another admin
|
||||||
|
*/
|
||||||
|
export async function impersonateUser(adminId, targetUserId) {
|
||||||
|
// Verify the requester is an admin
|
||||||
|
const requesterIsAdmin = await isAdmin(adminId);
|
||||||
|
if (!requesterIsAdmin) {
|
||||||
|
throw new Error('Only admins can impersonate users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target is also an admin (prevent admin impersonation)
|
||||||
|
if (targetUser.isAdmin) {
|
||||||
|
throw new Error('Cannot impersonate another admin user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log impersonation action
|
||||||
|
await logAdminAction(adminId, 'impersonate_start', targetUserId, {
|
||||||
|
targetEmail: targetUser.email,
|
||||||
|
targetCallsign: targetUser.callsign,
|
||||||
|
});
|
||||||
|
|
||||||
|
return targetUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify impersonation token is valid
|
||||||
|
* @param {Object} impersonationToken - JWT token payload containing impersonation data
|
||||||
|
* @returns {Promise<Object>} Verification result with target user data
|
||||||
|
*/
|
||||||
|
export async function verifyImpersonation(impersonationToken) {
|
||||||
|
const { adminId, targetUserId, exp } = impersonationToken;
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
if (Date.now() > exp * 1000) {
|
||||||
|
throw new Error('Impersonation token has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin still exists and is admin
|
||||||
|
const adminUser = await getUserByIdFull(adminId);
|
||||||
|
if (!adminUser || !adminUser.isAdmin) {
|
||||||
|
throw new Error('Invalid impersonation: Admin no longer exists or is not admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return target user with admin metadata for frontend display
|
||||||
|
return {
|
||||||
|
...targetUser,
|
||||||
|
impersonating: {
|
||||||
|
adminId,
|
||||||
|
adminEmail: adminUser.email,
|
||||||
|
adminCallsign: adminUser.callsign,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop impersonating a user
|
||||||
|
* @param {number} adminId - Admin user ID
|
||||||
|
* @param {number} targetUserId - Target user ID being impersonated
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function stopImpersonation(adminId, targetUserId) {
|
||||||
|
await logAdminAction(adminId, 'impersonate_stop', targetUserId, {
|
||||||
|
message: 'Impersonation session ended',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get impersonation status for an admin
|
||||||
|
* @param {number} adminId - Admin user ID
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {number} options.limit - Number of recent impersonations to return
|
||||||
|
* @returns {Promise<Array>} Array of recent impersonation actions
|
||||||
|
*/
|
||||||
|
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
||||||
|
const impersonations = await db
|
||||||
|
.select({
|
||||||
|
id: adminActions.id,
|
||||||
|
actionType: adminActions.actionType,
|
||||||
|
targetUserId: adminActions.targetUserId,
|
||||||
|
targetEmail: sql`target_users.email`,
|
||||||
|
targetCallsign: sql`target_users.callsign`,
|
||||||
|
details: adminActions.details,
|
||||||
|
createdAt: adminActions.createdAt,
|
||||||
|
})
|
||||||
|
.from(adminActions)
|
||||||
|
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
||||||
|
.where(eq(adminActions.adminId, adminId))
|
||||||
|
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`)
|
||||||
|
.orderBy(desc(adminActions.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return impersonations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user role (admin operation)
|
||||||
|
* @param {number} adminId - Admin user ID making the change
|
||||||
|
* @param {number} targetUserId - User ID to update
|
||||||
|
* @param {string} newRole - New role ('user' or 'admin')
|
||||||
|
* @param {boolean} newIsAdmin - New admin flag
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws {Error} If not admin or would remove last admin
|
||||||
|
*/
|
||||||
|
export async function changeUserRole(adminId, targetUserId, newRole, newIsAdmin) {
|
||||||
|
// Verify the requester is an admin
|
||||||
|
const requesterIsAdmin = await isAdmin(adminId);
|
||||||
|
if (!requesterIsAdmin) {
|
||||||
|
throw new Error('Only admins can change user roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If demoting from admin, check if this would remove the last admin
|
||||||
|
if (targetUser.isAdmin && !newIsAdmin) {
|
||||||
|
const adminCount = await db
|
||||||
|
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.isAdmin, 1));
|
||||||
|
|
||||||
|
if (adminCount[0].count === 1) {
|
||||||
|
throw new Error('Cannot demote the last admin user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update role
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
role: newRole,
|
||||||
|
isAdmin: newIsAdmin ? 1 : 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, targetUserId));
|
||||||
|
|
||||||
|
// Log action
|
||||||
|
await logAdminAction(adminId, 'role_change', targetUserId, {
|
||||||
|
oldRole: targetUser.role,
|
||||||
|
newRole: newRole,
|
||||||
|
oldIsAdmin: targetUser.isAdmin,
|
||||||
|
newIsAdmin: newIsAdmin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user (admin operation)
|
||||||
|
* @param {number} adminId - Admin user ID making the change
|
||||||
|
* @param {number} targetUserId - User ID to delete
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws {Error} If not admin, trying to delete self, or trying to delete another admin
|
||||||
|
*/
|
||||||
|
export async function deleteUser(adminId, targetUserId) {
|
||||||
|
// Verify the requester is an admin
|
||||||
|
const requesterIsAdmin = await isAdmin(adminId);
|
||||||
|
if (!requesterIsAdmin) {
|
||||||
|
throw new Error('Only admins can delete users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting self
|
||||||
|
if (adminId === targetUserId) {
|
||||||
|
throw new Error('Cannot delete your own account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting other admins
|
||||||
|
if (targetUser.isAdmin) {
|
||||||
|
throw new Error('Cannot delete admin users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stats for logging
|
||||||
|
const [qsoStats] = await db
|
||||||
|
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||||
|
.from(qsos)
|
||||||
|
.where(eq(qsos.userId, targetUserId));
|
||||||
|
|
||||||
|
// Delete all related records using Drizzle
|
||||||
|
// Delete in correct order to satisfy foreign key constraints
|
||||||
|
logger.info('Attempting to delete user', { userId: targetUserId, adminId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Delete qso_changes (references qso_id -> qsos and job_id -> sync_jobs)
|
||||||
|
// First get user's QSO IDs, then delete qso_changes referencing those QSOs
|
||||||
|
const userQSOs = await db.select({ id: qsos.id }).from(qsos).where(eq(qsos.userId, targetUserId));
|
||||||
|
const userQSOIds = userQSOs.map(q => q.id);
|
||||||
|
|
||||||
|
if (userQSOIds.length > 0) {
|
||||||
|
// Use raw SQL to delete qso_changes
|
||||||
|
sqlite.exec(
|
||||||
|
`DELETE FROM qso_changes WHERE qso_id IN (${userQSOIds.join(',')})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete award_progress
|
||||||
|
await db.delete(awardProgress).where(eq(awardProgress.userId, targetUserId));
|
||||||
|
|
||||||
|
// 3. Delete sync_jobs
|
||||||
|
await db.delete(syncJobs).where(eq(syncJobs.userId, targetUserId));
|
||||||
|
|
||||||
|
// 4. Delete qsos
|
||||||
|
await db.delete(qsos).where(eq(qsos.userId, targetUserId));
|
||||||
|
|
||||||
|
// 5. Delete admin actions where user is target
|
||||||
|
await db.delete(adminActions).where(eq(adminActions.targetUserId, targetUserId));
|
||||||
|
|
||||||
|
// 6. Delete user
|
||||||
|
await db.delete(users).where(eq(users.id, targetUserId));
|
||||||
|
|
||||||
|
// Log action
|
||||||
|
await logAdminAction(adminId, 'user_delete', targetUserId, {
|
||||||
|
email: targetUser.email,
|
||||||
|
callsign: targetUser.callsign,
|
||||||
|
qsoCountDeleted: qsoStats.count,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('User deleted successfully', { userId: targetUserId, adminId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete user', { error: error.message, userId: targetUserId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log action
|
||||||
|
await logAdminAction(adminId, 'user_delete', targetUserId, {
|
||||||
|
email: targetUser.email,
|
||||||
|
callsign: targetUser.callsign,
|
||||||
|
qsoCountDeleted: qsoStats.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -142,3 +142,102 @@ export async function updateDCLCredentials(userId, dclApiKey) {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,3 +86,35 @@ export const jobsAPI = {
|
|||||||
getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`),
|
getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`),
|
||||||
cancel: (jobId) => apiRequest(`/jobs/${jobId}`, { method: 'DELETE' }),
|
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}`),
|
||||||
|
};
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
<a href="/awards" class="nav-link">Awards</a>
|
<a href="/awards" class="nav-link">Awards</a>
|
||||||
<a href="/qsos" class="nav-link">QSOs</a>
|
<a href="/qsos" class="nav-link">QSOs</a>
|
||||||
<a href="/settings" class="nav-link">Settings</a>
|
<a href="/settings" class="nav-link">Settings</a>
|
||||||
|
{#if $auth.user?.isAdmin}
|
||||||
|
<a href="/admin" class="nav-link admin-link">Admin</a>
|
||||||
|
{/if}
|
||||||
<button on:click={handleLogout} class="nav-link logout-btn">Logout</button>
|
<button on:click={handleLogout} class="nav-link logout-btn">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,6 +122,16 @@
|
|||||||
background-color: rgba(255, 107, 107, 0.1);
|
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 {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
|
|||||||
1016
src/frontend/src/routes/admin/+page.svelte
Normal file
1016
src/frontend/src/routes/admin/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user