feat: add super-admin role with admin impersonation support
Add a new super-admin role that can impersonate other admins. Regular admins retain all existing permissions but cannot impersonate other admins or promote users to super-admin. Backend changes: - Add isSuperAdmin field to users table with default false - Add isSuperAdmin() check function to auth service - Update JWT tokens to include isSuperAdmin claim - Allow super-admins to impersonate other admins - Add security rules for super-admin role changes Frontend changes: - Display "Super Admin" badge with gradient styling - Add "Super Admin" option to role change modal - Enable impersonate button for super-admins targeting admins - Add "Super Admins Only" filter option Security rules: - Only super-admins can promote/demote super-admins - Regular admins cannot promote users to super-admin - Super-admins cannot demote themselves - Cannot demote the last super-admin Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
17
drizzle/0004_overrated_havok.sql
Normal file
17
drizzle/0004_overrated_havok.sql
Normal file
@@ -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;
|
||||||
868
drizzle/meta/0004_snapshot.json
Normal file
868
drizzle/meta/0004_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1768989260562,
|
"when": 1768989260562,
|
||||||
"tag": "0003_tired_warpath",
|
"tag": "0003_tired_warpath",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769171258085,
|
||||||
|
"tag": "0004_overrated_havok",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|||||||
* @property {string|null} lotwPassword
|
* @property {string|null} lotwPassword
|
||||||
* @property {string|null} dclApiKey
|
* @property {string|null} dclApiKey
|
||||||
* @property {boolean} isAdmin
|
* @property {boolean} isAdmin
|
||||||
|
* @property {boolean} isSuperAdmin
|
||||||
* @property {Date|null} lastSeen
|
* @property {Date|null} lastSeen
|
||||||
* @property {Date} createdAt
|
* @property {Date} createdAt
|
||||||
* @property {Date} updatedAt
|
* @property {Date} updatedAt
|
||||||
@@ -24,6 +25,7 @@ export const users = sqliteTable('users', {
|
|||||||
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
|
||||||
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
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' }),
|
lastSeen: integer('last_seen', { mode: 'timestamp' }),
|
||||||
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()),
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ const app = new Elysia()
|
|||||||
email: payload.email,
|
email: payload.email,
|
||||||
callsign: payload.callsign,
|
callsign: payload.callsign,
|
||||||
isAdmin: payload.isAdmin,
|
isAdmin: payload.isAdmin,
|
||||||
|
isSuperAdmin: payload.isSuperAdmin,
|
||||||
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
|
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
|
||||||
},
|
},
|
||||||
isImpersonation,
|
isImpersonation,
|
||||||
@@ -360,6 +361,8 @@ const app = new Elysia()
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isSuperAdmin: user.isSuperAdmin,
|
||||||
exp,
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -429,6 +432,7 @@ const app = new Elysia()
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
|
isSuperAdmin: user.isSuperAdmin,
|
||||||
exp,
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1209,7 +1213,7 @@ const app = new Elysia()
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/users/:userId/role
|
* 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 }) => {
|
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
|
||||||
if (!user || !user.isAdmin) {
|
if (!user || !user.isAdmin) {
|
||||||
@@ -1223,21 +1227,27 @@ const app = new Elysia()
|
|||||||
return { success: false, error: 'Invalid user ID' };
|
return { success: false, error: 'Invalid user ID' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isAdmin } = body;
|
const { role } = body;
|
||||||
|
|
||||||
if (typeof isAdmin !== 'boolean') {
|
if (typeof role !== 'string') {
|
||||||
set.status = 400;
|
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 {
|
try {
|
||||||
await changeUserRole(user.id, targetUserId, isAdmin);
|
await changeUserRole(user.id, targetUserId, role);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'User admin status updated successfully',
|
message: 'User role updated successfully',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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;
|
set.status = 400;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1304,6 +1314,7 @@ const app = new Elysia()
|
|||||||
email: targetUser.email,
|
email: targetUser.email,
|
||||||
callsign: targetUser.callsign,
|
callsign: targetUser.callsign,
|
||||||
isAdmin: targetUser.isAdmin,
|
isAdmin: targetUser.isAdmin,
|
||||||
|
isSuperAdmin: targetUser.isSuperAdmin,
|
||||||
impersonatedBy: user.id, // Admin ID who started impersonation
|
impersonatedBy: user.id, // Admin ID who started impersonation
|
||||||
exp,
|
exp,
|
||||||
});
|
});
|
||||||
@@ -1364,6 +1375,7 @@ const app = new Elysia()
|
|||||||
email: adminUser.email,
|
email: adminUser.email,
|
||||||
callsign: adminUser.callsign,
|
callsign: adminUser.callsign,
|
||||||
isAdmin: adminUser.isAdmin,
|
isAdmin: adminUser.isAdmin,
|
||||||
|
isSuperAdmin: adminUser.isSuperAdmin,
|
||||||
exp,
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq, sql, desc } from 'drizzle-orm';
|
import { eq, sql, desc } from 'drizzle-orm';
|
||||||
import { db, sqlite, logger } from '../config.js';
|
import { db, sqlite, logger } from '../config.js';
|
||||||
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.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
|
* Log an admin action for audit trail
|
||||||
@@ -160,7 +160,7 @@ export async function getUserStats() {
|
|||||||
* @param {number} adminId - Admin user ID
|
* @param {number} adminId - Admin user ID
|
||||||
* @param {number} targetUserId - Target user ID to impersonate
|
* @param {number} targetUserId - Target user ID to impersonate
|
||||||
* @returns {Promise<Object>} Target user object
|
* @returns {Promise<Object>} 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) {
|
export async function impersonateUser(adminId, targetUserId) {
|
||||||
// Verify the requester is an admin
|
// Verify the requester is an admin
|
||||||
@@ -175,9 +175,17 @@ export async function impersonateUser(adminId, targetUserId) {
|
|||||||
throw new Error('Target user not found');
|
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) {
|
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
|
// Log impersonation action
|
||||||
@@ -271,48 +279,69 @@ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
|||||||
* Update user admin status (admin operation)
|
* Update user admin status (admin operation)
|
||||||
* @param {number} adminId - Admin user ID making the change
|
* @param {number} adminId - Admin user ID making the change
|
||||||
* @param {number} targetUserId - User ID to update
|
* @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<void>}
|
* @returns {Promise<void>}
|
||||||
* @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
|
// Verify the requester is an admin
|
||||||
const requesterIsAdmin = await isAdmin(adminId);
|
const requesterIsAdmin = await isAdmin(adminId);
|
||||||
if (!requesterIsAdmin) {
|
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
|
// Get target user
|
||||||
const targetUser = await getUserByIdFull(targetUserId);
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
throw new Error('Target user not found');
|
throw new Error('Target user not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If demoting from admin, check if this would remove the last admin
|
// Security rules for super-admin role changes
|
||||||
if (targetUser.isAdmin && !newIsAdmin) {
|
const targetWillBeSuperAdmin = newRole === 'super-admin';
|
||||||
const adminCount = await db
|
const targetIsCurrentlySuperAdmin = targetUser.isSuperAdmin;
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)` })
|
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.isAdmin, 1));
|
.where(eq(users.isSuperAdmin, 1));
|
||||||
|
|
||||||
if (adminCount[0].count === 1) {
|
if (superAdminCount[0].count === 1) {
|
||||||
throw new Error('Cannot demote the last admin user');
|
throw new Error('Cannot demote the last super-admin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update admin status
|
// Update role (use the auth service function)
|
||||||
await db
|
await updateUserRole(targetUserId, newRole);
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
isAdmin: newIsAdmin ? 1 : 0,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(users.id, targetUserId));
|
|
||||||
|
|
||||||
// Log action
|
// Log action
|
||||||
await logAdminAction(adminId, 'role_change', targetUserId, {
|
await logAdminAction(adminId, 'role_change', targetUserId, {
|
||||||
oldIsAdmin: targetUser.isAdmin,
|
oldRole: targetUser.isSuperAdmin ? 'super-admin' : (targetUser.isAdmin ? 'admin' : 'user'),
|
||||||
newIsAdmin: newIsAdmin,
|
newRole: newRole,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,21 @@ export async function isAdmin(userId) {
|
|||||||
return user?.isAdmin === true || user?.isAdmin === 1;
|
return user?.isAdmin === true || user?.isAdmin === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is super-admin
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {Promise<boolean>} 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
|
* Get all admin users
|
||||||
* @returns {Promise<Array>} Array of admin users (without passwords)
|
* @returns {Promise<Array>} 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 {number} userId - User ID
|
||||||
* @param {boolean} isAdmin - Admin flag
|
* @param {string} role - Role: 'user', 'admin', or 'super-admin'
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
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
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
isAdmin: isAdmin ? 1 : 0,
|
isAdmin: isAdmin ? 1 : 0,
|
||||||
|
isSuperAdmin: isSuperAdmin ? 1 : 0,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
@@ -204,6 +223,7 @@ export async function getAllUsers() {
|
|||||||
email: users.email,
|
email: users.email,
|
||||||
callsign: users.callsign,
|
callsign: users.callsign,
|
||||||
isAdmin: users.isAdmin,
|
isAdmin: users.isAdmin,
|
||||||
|
isSuperAdmin: users.isSuperAdmin,
|
||||||
lastSeen: users.lastSeen,
|
lastSeen: users.lastSeen,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
updatedAt: users.updatedAt,
|
updatedAt: users.updatedAt,
|
||||||
@@ -226,6 +246,7 @@ export async function getUserByIdFull(userId) {
|
|||||||
email: users.email,
|
email: users.email,
|
||||||
callsign: users.callsign,
|
callsign: users.callsign,
|
||||||
isAdmin: users.isAdmin,
|
isAdmin: users.isAdmin,
|
||||||
|
isSuperAdmin: users.isSuperAdmin,
|
||||||
lotwUsername: users.lotwUsername,
|
lotwUsername: users.lotwUsername,
|
||||||
dclApiKey: users.dclApiKey,
|
dclApiKey: users.dclApiKey,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ export const adminAPI = {
|
|||||||
|
|
||||||
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
|
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
|
||||||
|
|
||||||
updateUserRole: (userId, isAdmin) => apiRequest(`/admin/users/${userId}/role`, {
|
updateUserRole: (userId, role) => apiRequest(`/admin/users/${userId}/role`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ isAdmin }),
|
body: JSON.stringify({ role }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {
|
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {
|
||||||
|
|||||||
@@ -141,19 +141,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRoleChange(userId, newIsAdmin) {
|
async function handleRoleChange(userId, newRole) {
|
||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
const data = await adminAPI.updateUserRole(userId, newIsAdmin);
|
const data = await adminAPI.updateUserRole(userId, newRole);
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(data.message);
|
alert(data.message);
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to update user admin status: ' + (data.error || 'Unknown error'));
|
alert('Failed to update user role: ' + (data.error || 'Unknown error'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to update user admin status: ' + err.message);
|
alert('Failed to update user role: ' + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
showRoleChangeModal = false;
|
showRoleChangeModal = false;
|
||||||
@@ -197,7 +197,8 @@
|
|||||||
user.callsign.toLowerCase().includes(userSearch.toLowerCase());
|
user.callsign.toLowerCase().includes(userSearch.toLowerCase());
|
||||||
|
|
||||||
const matchesFilter = userFilter === 'all' ||
|
const matchesFilter = userFilter === 'all' ||
|
||||||
(userFilter === 'admin' && user.isAdmin) ||
|
(userFilter === 'super-admin' && user.isSuperAdmin) ||
|
||||||
|
(userFilter === 'admin' && user.isAdmin && !user.isSuperAdmin) ||
|
||||||
(userFilter === 'user' && !user.isAdmin);
|
(userFilter === 'user' && !user.isAdmin);
|
||||||
|
|
||||||
return matchesSearch && matchesFilter;
|
return matchesSearch && matchesFilter;
|
||||||
@@ -317,6 +318,7 @@
|
|||||||
/>
|
/>
|
||||||
<select class="filter-select" bind:value={userFilter}>
|
<select class="filter-select" bind:value={userFilter}>
|
||||||
<option value="all">All Users</option>
|
<option value="all">All Users</option>
|
||||||
|
<option value="super-admin">Super Admins Only</option>
|
||||||
<option value="admin">Admins Only</option>
|
<option value="admin">Admins Only</option>
|
||||||
<option value="user">Regular Users Only</option>
|
<option value="user">Regular Users Only</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -347,8 +349,8 @@
|
|||||||
<td>{user.email}</td>
|
<td>{user.email}</td>
|
||||||
<td>{user.callsign}</td>
|
<td>{user.callsign}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="role-badge {user.isAdmin ? 'admin' : 'user'}">
|
<span class="role-badge {user.isSuperAdmin ? 'super-admin' : (user.isAdmin ? 'admin' : 'user')}">
|
||||||
{user.isAdmin ? 'Admin' : 'User'}
|
{user.isSuperAdmin ? 'Super Admin' : (user.isAdmin ? 'Admin' : 'User')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{user.qsoCount || 0}</td>
|
<td>{user.qsoCount || 0}</td>
|
||||||
@@ -361,7 +363,7 @@
|
|||||||
<button
|
<button
|
||||||
class="action-button impersonate-btn"
|
class="action-button impersonate-btn"
|
||||||
on:click={() => openImpersonationModal(user)}
|
on:click={() => openImpersonationModal(user)}
|
||||||
disabled={user.isAdmin}
|
disabled={user.isAdmin && !$auth.user.isSuperAdmin}
|
||||||
>
|
>
|
||||||
Impersonate
|
Impersonate
|
||||||
</button>
|
</button>
|
||||||
@@ -495,25 +497,34 @@
|
|||||||
<div class="modal-content" on:click|stopPropagation>
|
<div class="modal-content" on:click|stopPropagation>
|
||||||
<h2>Change User Role</h2>
|
<h2>Change User Role</h2>
|
||||||
<p>User: <strong>{selectedUser.email}</strong></p>
|
<p>User: <strong>{selectedUser.email}</strong></p>
|
||||||
<p>Current Role: <strong>{selectedUser.isAdmin ? 'Admin' : 'User'}</strong></p>
|
<p>Current Role: <strong>{selectedUser.isSuperAdmin ? 'Super Admin' : (selectedUser.isAdmin ? 'Admin' : 'User')}</strong></p>
|
||||||
<p>New Role:</p>
|
<p>New Role:</p>
|
||||||
<div class="role-options">
|
<div class="role-options">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="role" value="user" checked={!selectedUser.isAdmin} />
|
<input type="radio" name="role" value="user" checked={!selectedUser.isAdmin && !selectedUser.isSuperAdmin} />
|
||||||
Regular User
|
Regular User
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="role" value="admin" checked={selectedUser.isAdmin} />
|
<input type="radio" name="role" value="admin" checked={selectedUser.isAdmin && !selectedUser.isSuperAdmin} />
|
||||||
Admin
|
Admin
|
||||||
</label>
|
</label>
|
||||||
|
{#if $auth.user.isSuperAdmin}
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="role" value="super-admin" checked={selectedUser.isSuperAdmin} />
|
||||||
|
Super Admin
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !$auth.user.isSuperAdmin && (selectedUser.isSuperAdmin || (selectedUser.isAdmin && selectedUser.email !== selectedUser.email))}
|
||||||
|
<p class="warning">Note: Only super-admins can promote or demote super-admins.</p>
|
||||||
|
{/if}
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="modal-button cancel" on:click={() => showRoleChangeModal = false}>
|
<button class="modal-button cancel" on:click={() => showRoleChangeModal = false}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="modal-button confirm"
|
class="modal-button confirm"
|
||||||
on:click={() => handleRoleChange(selectedUser.id, !selectedUser.isAdmin)}
|
on:click={() => handleRoleChange(selectedUser.id, document.querySelector('input[name="role"]:checked')?.value || 'user')}
|
||||||
>
|
>
|
||||||
Change Role
|
Change Role
|
||||||
</button>
|
</button>
|
||||||
@@ -776,6 +787,11 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-badge.super-admin {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.role-badge.user {
|
.role-badge.user {
|
||||||
background-color: var(--color-secondary);
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
Reference in New Issue
Block a user