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:
2026-01-23 13:32:55 +01:00
parent a5f0e3b96f
commit ed433902d9
9 changed files with 1022 additions and 50 deletions

View File

@@ -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()),

View File

@@ -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,
});

View File

@@ -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<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) {
// 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<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
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,
});
}

View File

@@ -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<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
* @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 {boolean} isAdmin - Admin flag
* @param {string} role - Role: 'user', 'admin', or 'super-admin'
* @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
.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,

View File

@@ -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}`, {

View File

@@ -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 @@
/>
<select class="filter-select" bind:value={userFilter}>
<option value="all">All Users</option>
<option value="super-admin">Super Admins Only</option>
<option value="admin">Admins Only</option>
<option value="user">Regular Users Only</option>
</select>
@@ -347,8 +349,8 @@
<td>{user.email}</td>
<td>{user.callsign}</td>
<td>
<span class="role-badge {user.isAdmin ? 'admin' : 'user'}">
{user.isAdmin ? 'Admin' : 'User'}
<span class="role-badge {user.isSuperAdmin ? 'super-admin' : (user.isAdmin ? 'admin' : 'user')}">
{user.isSuperAdmin ? 'Super Admin' : (user.isAdmin ? 'Admin' : 'User')}
</span>
</td>
<td>{user.qsoCount || 0}</td>
@@ -361,7 +363,7 @@
<button
class="action-button impersonate-btn"
on:click={() => openImpersonationModal(user)}
disabled={user.isAdmin}
disabled={user.isAdmin && !$auth.user.isSuperAdmin}
>
Impersonate
</button>
@@ -495,25 +497,34 @@
<div class="modal-content" on:click|stopPropagation>
<h2>Change User Role</h2>
<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>
<div class="role-options">
<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
</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
</label>
{#if $auth.user.isSuperAdmin}
<label>
<input type="radio" name="role" value="super-admin" checked={selectedUser.isSuperAdmin} />
Super Admin
</label>
{/if}
</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">
<button class="modal-button cancel" on:click={() => showRoleChangeModal = false}>
Cancel
</button>
<button
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
</button>
@@ -776,6 +787,11 @@
color: white;
}
.role-badge.super-admin {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.role-badge.user {
background-color: var(--color-secondary);
color: white;