diff --git a/drizzle/0004_overrated_havok.sql b/drizzle/0004_overrated_havok.sql new file mode 100644 index 0000000..c0be3b2 --- /dev/null +++ b/drizzle/0004_overrated_havok.sql @@ -0,0 +1,17 @@ +CREATE TABLE `auto_sync_settings` ( + `user_id` integer PRIMARY KEY NOT NULL, + `lotw_enabled` integer DEFAULT false NOT NULL, + `lotw_interval_hours` integer DEFAULT 24 NOT NULL, + `lotw_last_sync_at` integer, + `lotw_next_sync_at` integer, + `dcl_enabled` integer DEFAULT false NOT NULL, + `dcl_interval_hours` integer DEFAULT 24 NOT NULL, + `dcl_last_sync_at` integer, + `dcl_next_sync_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +ALTER TABLE `users` ADD `is_super_admin` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `last_seen` integer; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..0864fc2 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,868 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0d928d09-61c6-4311-beb8-0f597172e510", + "prevId": "071c98fb-6721-4da7-98cb-c16cb6aaf0c1", + "tables": { + "admin_actions": { + "name": "admin_actions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "admin_actions_admin_id_users_id_fk": { + "name": "admin_actions_admin_id_users_id_fk", + "tableFrom": "admin_actions", + "tableTo": "users", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_actions_target_user_id_users_id_fk": { + "name": "admin_actions_target_user_id_users_id_fk", + "tableFrom": "admin_actions", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auto_sync_settings": { + "name": "auto_sync_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lotw_enabled": { + "name": "lotw_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lotw_interval_hours": { + "name": "lotw_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 24 + }, + "lotw_last_sync_at": { + "name": "lotw_last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lotw_next_sync_at": { + "name": "lotw_next_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dcl_enabled": { + "name": "dcl_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "dcl_interval_hours": { + "name": "dcl_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 24 + }, + "dcl_last_sync_at": { + "name": "dcl_last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dcl_next_sync_at": { + "name": "dcl_next_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "auto_sync_settings_user_id_users_id_fk": { + "name": "auto_sync_settings_user_id_users_id_fk", + "tableFrom": "auto_sync_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "award_progress": { + "name": "award_progress", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "award_id": { + "name": "award_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worked_count": { + "name": "worked_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "confirmed_count": { + "name": "confirmed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_required": { + "name": "total_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worked_entities": { + "name": "worked_entities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirmed_entities": { + "name": "confirmed_entities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_calculated_at": { + "name": "last_calculated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_qso_sync_at": { + "name": "last_qso_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "award_progress_user_id_users_id_fk": { + "name": "award_progress_user_id_users_id_fk", + "tableFrom": "award_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "award_progress_award_id_awards_id_fk": { + "name": "award_progress_award_id_awards_id_fk", + "tableFrom": "award_progress", + "tableTo": "awards", + "columnsFrom": [ + "award_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "awards": { + "name": "awards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "definition": { + "name": "definition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qso_changes": { + "name": "qso_changes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "job_id": { + "name": "job_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qso_id": { + "name": "qso_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_data": { + "name": "before_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after_data": { + "name": "after_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "qso_changes_job_id_sync_jobs_id_fk": { + "name": "qso_changes_job_id_sync_jobs_id_fk", + "tableFrom": "qso_changes", + "tableTo": "sync_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "qso_changes_qso_id_qsos_id_fk": { + "name": "qso_changes_qso_id_qsos_id_fk", + "tableFrom": "qso_changes", + "tableTo": "qsos", + "columnsFrom": [ + "qso_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qsos": { + "name": "qsos", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "callsign": { + "name": "callsign", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qso_date": { + "name": "qso_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_on": { + "name": "time_on", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "band": { + "name": "band", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "freq": { + "name": "freq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "freq_rx": { + "name": "freq_rx", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grid": { + "name": "grid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grid_source": { + "name": "grid_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "continent": { + "name": "continent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cq_zone": { + "name": "cq_zone", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "itu_zone": { + "name": "itu_zone", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sat_name": { + "name": "sat_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sat_mode": { + "name": "sat_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "my_darc_dok": { + "name": "my_darc_dok", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "darc_dok": { + "name": "darc_dok", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lotw_qsl_rdate": { + "name": "lotw_qsl_rdate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lotw_qsl_rstatus": { + "name": "lotw_qsl_rstatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dcl_qsl_rdate": { + "name": "dcl_qsl_rdate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dcl_qsl_rstatus": { + "name": "dcl_qsl_rstatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lotw_synced_at": { + "name": "lotw_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "qsos_user_id_users_id_fk": { + "name": "qsos_user_id_users_id_fk", + "tableFrom": "qsos", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_jobs": { + "name": "sync_jobs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_jobs_user_id_users_id_fk": { + "name": "sync_jobs_user_id_users_id_fk", + "tableFrom": "sync_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "callsign": { + "name": "callsign", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lotw_username": { + "name": "lotw_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lotw_password": { + "name": "lotw_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dcl_api_key": { + "name": "dcl_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_seen": { + "name": "last_seen", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 693fa4a..ec48e31 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1768989260562, "tag": "0003_tired_warpath", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1769171258085, + "tag": "0004_overrated_havok", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/backend/db/schema/index.js b/src/backend/db/schema/index.js index 7356c80..d851799 100644 --- a/src/backend/db/schema/index.js +++ b/src/backend/db/schema/index.js @@ -10,6 +10,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; * @property {string|null} lotwPassword * @property {string|null} dclApiKey * @property {boolean} isAdmin + * @property {boolean} isSuperAdmin * @property {Date|null} lastSeen * @property {Date} createdAt * @property {Date} updatedAt @@ -24,6 +25,7 @@ export const users = sqliteTable('users', { lotwPassword: text('lotw_password'), // Encrypted dclApiKey: text('dcl_api_key'), // DCL API key for future use isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), + isSuperAdmin: integer('is_super_admin', { mode: 'boolean' }).notNull().default(false), lastSeen: integer('last_seen', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), diff --git a/src/backend/index.js b/src/backend/index.js index 32ac8d7..e67b26c 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -225,6 +225,7 @@ const app = new Elysia() email: payload.email, callsign: payload.callsign, isAdmin: payload.isAdmin, + isSuperAdmin: payload.isSuperAdmin, impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating }, isImpersonation, @@ -360,6 +361,8 @@ const app = new Elysia() userId: user.id, email: user.email, callsign: user.callsign, + isAdmin: user.isAdmin, + isSuperAdmin: user.isSuperAdmin, exp, }); @@ -429,6 +432,7 @@ const app = new Elysia() email: user.email, callsign: user.callsign, isAdmin: user.isAdmin, + isSuperAdmin: user.isSuperAdmin, exp, }); @@ -1209,7 +1213,7 @@ const app = new Elysia() /** * POST /api/admin/users/:userId/role - * Update user admin status (admin only) + * Update user role (admin only) */ .post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => { if (!user || !user.isAdmin) { @@ -1223,21 +1227,27 @@ const app = new Elysia() return { success: false, error: 'Invalid user ID' }; } - const { isAdmin } = body; + const { role } = body; - if (typeof isAdmin !== 'boolean') { + if (typeof role !== 'string') { set.status = 400; - return { success: false, error: 'isAdmin (boolean) is required' }; + return { success: false, error: 'role (string) is required' }; + } + + const validRoles = ['user', 'admin', 'super-admin']; + if (!validRoles.includes(role)) { + set.status = 400; + return { success: false, error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }; } try { - await changeUserRole(user.id, targetUserId, isAdmin); + await changeUserRole(user.id, targetUserId, role); return { success: true, - message: 'User admin status updated successfully', + message: 'User role updated successfully', }; } catch (error) { - logger.error('Error updating user admin status', { error: error.message, userId: user.id }); + logger.error('Error updating user role', { error: error.message, userId: user.id }); set.status = 400; return { success: false, @@ -1304,6 +1314,7 @@ const app = new Elysia() email: targetUser.email, callsign: targetUser.callsign, isAdmin: targetUser.isAdmin, + isSuperAdmin: targetUser.isSuperAdmin, impersonatedBy: user.id, // Admin ID who started impersonation exp, }); @@ -1364,6 +1375,7 @@ const app = new Elysia() email: adminUser.email, callsign: adminUser.callsign, isAdmin: adminUser.isAdmin, + isSuperAdmin: adminUser.isSuperAdmin, exp, }); diff --git a/src/backend/services/admin.service.js b/src/backend/services/admin.service.js index 72f3d4c..05d3d1a 100644 --- a/src/backend/services/admin.service.js +++ b/src/backend/services/admin.service.js @@ -1,7 +1,7 @@ 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'; +import { getUserByIdFull, isAdmin, isSuperAdmin } from './auth.service.js'; /** * Log an admin action for audit trail @@ -160,7 +160,7 @@ export async function getUserStats() { * @param {number} adminId - Admin user ID * @param {number} targetUserId - Target user ID to impersonate * @returns {Promise} Target user object - * @throws {Error} If not admin or trying to impersonate another admin + * @throws {Error} If not admin or trying to impersonate another admin (without super-admin) */ export async function impersonateUser(adminId, targetUserId) { // Verify the requester is an admin @@ -175,9 +175,17 @@ export async function impersonateUser(adminId, targetUserId) { throw new Error('Target user not found'); } - // Check if target is also an admin (prevent admin impersonation) + // Check if target is also an admin if (targetUser.isAdmin) { - throw new Error('Cannot impersonate another admin user'); + // Only super-admins can impersonate other admins + const requesterIsSuperAdmin = await isSuperAdmin(adminId); + if (!requesterIsSuperAdmin) { + throw new Error('Cannot impersonate another admin user (super-admin required)'); + } + // Prevent self-impersonation (edge case) + if (adminId === targetUserId) { + throw new Error('Cannot impersonate yourself'); + } } // Log impersonation action @@ -271,48 +279,69 @@ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) { * Update user admin status (admin operation) * @param {number} adminId - Admin user ID making the change * @param {number} targetUserId - User ID to update - * @param {boolean} newIsAdmin - New admin flag + * @param {string} newRole - New role: 'user', 'admin', or 'super-admin' * @returns {Promise} - * @throws {Error} If not admin or would remove last admin + * @throws {Error} If not admin or violates security rules */ -export async function changeUserRole(adminId, targetUserId, newIsAdmin) { +export async function changeUserRole(adminId, targetUserId, newRole) { + // Validate role + const validRoles = ['user', 'admin', 'super-admin']; + if (!validRoles.includes(newRole)) { + throw new Error('Invalid role. Must be one of: user, admin, super-admin'); + } + // Verify the requester is an admin const requesterIsAdmin = await isAdmin(adminId); if (!requesterIsAdmin) { - throw new Error('Only admins can change user admin status'); + throw new Error('Only admins can change user roles'); } + // Get requester super-admin status + const requesterIsSuperAdmin = await isSuperAdmin(adminId); + // 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)); + // Security rules for super-admin role changes + const targetWillBeSuperAdmin = newRole === 'super-admin'; + const targetIsCurrentlySuperAdmin = targetUser.isSuperAdmin; - if (adminCount[0].count === 1) { - throw new Error('Cannot demote the last admin user'); + // Only super-admins can promote/demote super-admins + if (targetWillBeSuperAdmin || targetIsCurrentlySuperAdmin) { + if (!requesterIsSuperAdmin) { + throw new Error('Only super-admins can promote or demote super-admins'); } } - // Update admin status - await db - .update(users) - .set({ - isAdmin: newIsAdmin ? 1 : 0, - updatedAt: new Date(), - }) - .where(eq(users.id, targetUserId)); + // Prevent self-demotion (super-admins cannot demote themselves) + if (adminId === targetUserId) { + if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) { + throw new Error('Cannot demote yourself from super-admin'); + } + } + + // Cannot demote the last super-admin + if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) { + const superAdminCount = await db + .select({ count: sql`CAST(COUNT(*) AS INTEGER)` }) + .from(users) + .where(eq(users.isSuperAdmin, 1)); + + if (superAdminCount[0].count === 1) { + throw new Error('Cannot demote the last super-admin'); + } + } + + // Update role (use the auth service function) + await updateUserRole(targetUserId, newRole); // Log action await logAdminAction(adminId, 'role_change', targetUserId, { - oldIsAdmin: targetUser.isAdmin, - newIsAdmin: newIsAdmin, + oldRole: targetUser.isSuperAdmin ? 'super-admin' : (targetUser.isAdmin ? 'admin' : 'user'), + newRole: newRole, }); } diff --git a/src/backend/services/auth.service.js b/src/backend/services/auth.service.js index ee42183..8877db8 100644 --- a/src/backend/services/auth.service.js +++ b/src/backend/services/auth.service.js @@ -158,6 +158,21 @@ export async function isAdmin(userId) { return user?.isAdmin === true || user?.isAdmin === 1; } +/** + * Check if user is super-admin + * @param {number} userId - User ID + * @returns {Promise} True if user is super-admin + */ +export async function isSuperAdmin(userId) { + const [user] = await db + .select({ isSuperAdmin: users.isSuperAdmin }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user?.isSuperAdmin === true || user?.isSuperAdmin === 1; +} + /** * Get all admin users * @returns {Promise} Array of admin users (without passwords) @@ -178,16 +193,20 @@ export async function getAdminUsers() { } /** - * Update user admin status + * Update user role * @param {number} userId - User ID - * @param {boolean} isAdmin - Admin flag + * @param {string} role - Role: 'user', 'admin', or 'super-admin' * @returns {Promise} */ -export async function updateUserRole(userId, isAdmin) { +export async function updateUserRole(userId, role) { + const isAdmin = role === 'admin' || role === 'super-admin'; + const isSuperAdmin = role === 'super-admin'; + await db .update(users) .set({ isAdmin: isAdmin ? 1 : 0, + isSuperAdmin: isSuperAdmin ? 1 : 0, updatedAt: new Date(), }) .where(eq(users.id, userId)); @@ -204,6 +223,7 @@ export async function getAllUsers() { email: users.email, callsign: users.callsign, isAdmin: users.isAdmin, + isSuperAdmin: users.isSuperAdmin, lastSeen: users.lastSeen, createdAt: users.createdAt, updatedAt: users.updatedAt, @@ -226,6 +246,7 @@ export async function getUserByIdFull(userId) { email: users.email, callsign: users.callsign, isAdmin: users.isAdmin, + isSuperAdmin: users.isSuperAdmin, lotwUsername: users.lotwUsername, dclApiKey: users.dclApiKey, createdAt: users.createdAt, diff --git a/src/frontend/src/lib/api.js b/src/frontend/src/lib/api.js index 4987458..36b8088 100644 --- a/src/frontend/src/lib/api.js +++ b/src/frontend/src/lib/api.js @@ -95,9 +95,9 @@ export const adminAPI = { getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`), - updateUserRole: (userId, isAdmin) => apiRequest(`/admin/users/${userId}/role`, { + updateUserRole: (userId, role) => apiRequest(`/admin/users/${userId}/role`, { method: 'POST', - body: JSON.stringify({ isAdmin }), + body: JSON.stringify({ role }), }), deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, { diff --git a/src/frontend/src/routes/admin/+page.svelte b/src/frontend/src/routes/admin/+page.svelte index e03f0a4..57a5545 100644 --- a/src/frontend/src/routes/admin/+page.svelte +++ b/src/frontend/src/routes/admin/+page.svelte @@ -141,19 +141,19 @@ } } - async function handleRoleChange(userId, newIsAdmin) { + async function handleRoleChange(userId, newRole) { try { loading = true; - const data = await adminAPI.updateUserRole(userId, newIsAdmin); + const data = await adminAPI.updateUserRole(userId, newRole); if (data.success) { alert(data.message); await loadUsers(); } else { - alert('Failed to update user admin status: ' + (data.error || 'Unknown error')); + alert('Failed to update user role: ' + (data.error || 'Unknown error')); } } catch (err) { - alert('Failed to update user admin status: ' + err.message); + alert('Failed to update user role: ' + err.message); } finally { loading = false; showRoleChangeModal = false; @@ -197,7 +197,8 @@ user.callsign.toLowerCase().includes(userSearch.toLowerCase()); const matchesFilter = userFilter === 'all' || - (userFilter === 'admin' && user.isAdmin) || + (userFilter === 'super-admin' && user.isSuperAdmin) || + (userFilter === 'admin' && user.isAdmin && !user.isSuperAdmin) || (userFilter === 'user' && !user.isAdmin); return matchesSearch && matchesFilter; @@ -317,6 +318,7 @@ /> @@ -347,8 +349,8 @@ {user.email} {user.callsign} - - {user.isAdmin ? 'Admin' : 'User'} + + {user.isSuperAdmin ? 'Super Admin' : (user.isAdmin ? 'Admin' : 'User')} {user.qsoCount || 0} @@ -361,7 +363,7 @@ @@ -495,25 +497,34 @@