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,
|
||||
});
|
||||
|
||||
export { sqlite };
|
||||
|
||||
export async function closeDatabase() {
|
||||
sqlite.close();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`),
|
||||
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="/qsos" class="nav-link">QSOs</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
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