refactor: remove redundant role field, keep only is_admin

- Remove role column from users schema (migration 0003)
- Update auth and admin services to use is_admin only
- Remove role from JWT token payloads
- Update admin CLI to use is_admin field
- Update frontend admin page to use isAdmin boolean
- Fix security: remove console.log dumping credentials in settings

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-21 11:41:41 +01:00
parent fc44fef91a
commit 8f8abfc651
11 changed files with 789 additions and 62 deletions

View File

@@ -9,7 +9,6 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
* @property {string|null} lotwUsername
* @property {string|null} lotwPassword
* @property {string|null} dclApiKey
* @property {string} role
* @property {boolean} isAdmin
* @property {Date} createdAt
* @property {Date} updatedAt
@@ -23,8 +22,7 @@ export const users = sqliteTable('users', {
lotwUsername: text('lotw_username'),
lotwPassword: text('lotw_password'), // Encrypted
dclApiKey: text('dcl_api_key'), // DCL API key for future use
role: text('role').notNull().default('user'), // 'user', 'admin'
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Simplified admin check
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});

View File

@@ -195,7 +195,6 @@ const app = new Elysia()
id: payload.userId,
email: payload.email,
callsign: payload.callsign,
role: payload.role,
isAdmin: payload.isAdmin,
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
},
@@ -400,7 +399,6 @@ const app = new Elysia()
userId: user.id,
email: user.email,
callsign: user.callsign,
role: user.role,
isAdmin: user.isAdmin,
exp,
});
@@ -1139,7 +1137,7 @@ const app = new Elysia()
/**
* POST /api/admin/users/:userId/role
* Update user role (admin only)
* Update user admin status (admin only)
*/
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) {
@@ -1153,26 +1151,21 @@ const app = new Elysia()
return { success: false, error: 'Invalid user ID' };
}
const { role, isAdmin } = body;
const { isAdmin } = body;
if (!role || typeof isAdmin !== 'boolean') {
if (typeof isAdmin !== 'boolean') {
set.status = 400;
return { success: false, error: 'role and isAdmin are required' };
}
if (!['user', 'admin'].includes(role)) {
set.status = 400;
return { success: false, error: 'Invalid role' };
return { success: false, error: 'isAdmin (boolean) is required' };
}
try {
await changeUserRole(user.id, targetUserId, role, isAdmin);
await changeUserRole(user.id, targetUserId, isAdmin);
return {
success: true,
message: 'User role updated successfully',
message: 'User admin status updated successfully',
};
} catch (error) {
logger.error('Error updating user role', { error: error.message, userId: user.id });
logger.error('Error updating user admin status', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
@@ -1238,7 +1231,6 @@ const app = new Elysia()
userId: targetUser.id,
email: targetUser.email,
callsign: targetUser.callsign,
role: targetUser.role,
isAdmin: targetUser.isAdmin,
impersonatedBy: user.id, // Admin ID who started impersonation
exp,
@@ -1299,7 +1291,6 @@ const app = new Elysia()
userId: adminUser.id,
email: adminUser.email,
callsign: adminUser.callsign,
role: adminUser.role,
isAdmin: adminUser.isAdmin,
exp,
});

View File

@@ -69,15 +69,14 @@ function createAdminUser(email, password, callsign) {
// Insert admin user
const result = sqlite.query(`
INSERT INTO users (email, password_hash, callsign, role, is_admin, created_at, updated_at)
VALUES (?, ?, ?, 'admin', 1, strftime('%s', 'now') * 1000, strftime('%s', 'now') * 1000)
INSERT INTO users (email, password_hash, callsign, is_admin, created_at, updated_at)
VALUES (?, ?, ?, 1, strftime('%s', 'now') * 1000, strftime('%s', 'now') * 1000)
`).run(email, hashString, callsign);
console.log(`✓ Admin user created successfully!`);
console.log(` ID: ${result.lastInsertRowid}`);
console.log(` Email: ${email}`);
console.log(` Callsign: ${callsign}`);
console.log(` Role: admin`);
console.log(`\nYou can now log in with these credentials.`);
}
@@ -86,7 +85,7 @@ function promoteUser(email) {
// Check if user exists
const user = sqlite.query(`
SELECT id, email, role, is_admin FROM users WHERE email = ?
SELECT id, email, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
@@ -94,7 +93,7 @@ function promoteUser(email) {
process.exit(1);
}
if (user.role === 'admin' && user.is_admin === 1) {
if (user.is_admin === 1) {
console.log(`User ${email} is already an admin`);
return;
}
@@ -102,7 +101,7 @@ function promoteUser(email) {
// Update user to admin
sqlite.query(`
UPDATE users
SET role = 'admin', is_admin = 1, updated_at = strftime('%s', 'now') * 1000
SET is_admin = 1, updated_at = strftime('%s', 'now') * 1000
WHERE email = ?
`).run(email);
@@ -114,7 +113,7 @@ function demoteUser(email) {
// Check if user exists
const user = sqlite.query(`
SELECT id, email, role, is_admin FROM users WHERE email = ?
SELECT id, email, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
@@ -122,14 +121,14 @@ function demoteUser(email) {
process.exit(1);
}
if (user.role !== 'admin' || user.is_admin !== 1) {
if (user.is_admin !== 1) {
console.log(`User ${email} is not an admin`);
return;
}
// Check if this is the last admin
const adminCount = sqlite.query(`
SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND is_admin = 1
SELECT COUNT(*) as count FROM users WHERE is_admin = 1
`).get();
if (adminCount.count === 1) {
@@ -140,7 +139,7 @@ function demoteUser(email) {
// Update user to regular user
sqlite.query(`
UPDATE users
SET role = 'user', is_admin = 0, updated_at = strftime('%s', 'now') * 1000
SET is_admin = 0, updated_at = strftime('%s', 'now') * 1000
WHERE email = ?
`).run(email);
@@ -153,7 +152,7 @@ function listAdmins() {
const admins = sqlite.query(`
SELECT id, email, callsign, created_at
FROM users
WHERE role = 'admin' AND is_admin = 1
WHERE is_admin = 1
ORDER BY created_at ASC
`).all();
@@ -176,7 +175,7 @@ function checkUser(email) {
console.log(`Checking user status: ${email}\n`);
const user = sqlite.query(`
SELECT id, email, callsign, role, is_admin FROM users WHERE email = ?
SELECT id, email, callsign, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
@@ -184,12 +183,11 @@ function checkUser(email) {
process.exit(1);
}
const isAdmin = user.role === 'admin' && user.is_admin === 1;
const isAdmin = user.is_admin === 1;
console.log(`User found:`);
console.log(` Email: ${user.email}`);
console.log(` Callsign: ${user.callsign}`);
console.log(` Role: ${user.role}`);
console.log(` Is Admin: ${isAdmin ? 'Yes ✓' : 'No'}`);
}

View File

@@ -122,7 +122,6 @@ export async function getUserStats() {
id: users.id,
email: users.email,
callsign: users.callsign,
role: users.role,
isAdmin: users.isAdmin,
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
@@ -250,19 +249,18 @@ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
}
/**
* Update user role (admin operation)
* Update user admin status (admin operation)
* @param {number} adminId - Admin user ID making the change
* @param {number} targetUserId - User ID to update
* @param {string} newRole - New role ('user' or 'admin')
* @param {boolean} newIsAdmin - New admin flag
* @returns {Promise<void>}
* @throws {Error} If not admin or would remove last admin
*/
export async function changeUserRole(adminId, targetUserId, newRole, newIsAdmin) {
export async function changeUserRole(adminId, targetUserId, newIsAdmin) {
// Verify the requester is an admin
const requesterIsAdmin = await isAdmin(adminId);
if (!requesterIsAdmin) {
throw new Error('Only admins can change user roles');
throw new Error('Only admins can change user admin status');
}
// Get target user
@@ -283,11 +281,10 @@ export async function changeUserRole(adminId, targetUserId, newRole, newIsAdmin)
}
}
// Update role
// Update admin status
await db
.update(users)
.set({
role: newRole,
isAdmin: newIsAdmin ? 1 : 0,
updatedAt: new Date(),
})
@@ -295,8 +292,6 @@ export async function changeUserRole(adminId, targetUserId, newRole, newIsAdmin)
// Log action
await logAdminAction(adminId, 'role_change', targetUserId, {
oldRole: targetUser.role,
newRole: newRole,
oldIsAdmin: targetUser.isAdmin,
newIsAdmin: newIsAdmin,
});

View File

@@ -168,7 +168,6 @@ export async function getAdminUsers() {
id: users.id,
email: users.email,
callsign: users.callsign,
role: users.role,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
})
@@ -179,17 +178,15 @@ export async function getAdminUsers() {
}
/**
* Update user role
* Update user admin status
* @param {number} userId - User ID
* @param {string} role - New role ('user' or 'admin')
* @param {boolean} isAdmin - Admin flag
* @returns {Promise<void>}
*/
export async function updateUserRole(userId, role, isAdmin) {
export async function updateUserRole(userId, isAdmin) {
await db
.update(users)
.set({
role,
isAdmin: isAdmin ? 1 : 0,
updatedAt: new Date(),
})
@@ -206,7 +203,6 @@ export async function getAllUsers() {
id: users.id,
email: users.email,
callsign: users.callsign,
role: users.role,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
@@ -228,7 +224,6 @@ export async function getUserByIdFull(userId) {
id: users.id,
email: users.email,
callsign: users.callsign,
role: users.role,
isAdmin: users.isAdmin,
lotwUsername: users.lotwUsername,
dclApiKey: users.dclApiKey,

View File

@@ -95,9 +95,9 @@ export const adminAPI = {
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
updateUserRole: (userId, role, isAdmin) => apiRequest(`/admin/users/${userId}/role`, {
updateUserRole: (userId, isAdmin) => apiRequest(`/admin/users/${userId}/role`, {
method: 'POST',
body: JSON.stringify({ role, isAdmin }),
body: JSON.stringify({ isAdmin }),
}),
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {

View File

@@ -167,19 +167,19 @@
}
}
async function handleRoleChange(userId, newRole, newIsAdmin) {
async function handleRoleChange(userId, newIsAdmin) {
try {
loading = true;
const data = await adminAPI.updateUserRole(userId, newRole, newIsAdmin);
const data = await adminAPI.updateUserRole(userId, newIsAdmin);
if (data.success) {
alert(data.message);
await loadUsers();
} else {
alert('Failed to update user role: ' + (data.error || 'Unknown error'));
alert('Failed to update user admin status: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Failed to update user role: ' + err.message);
alert('Failed to update user admin status: ' + err.message);
} finally {
loading = false;
showRoleChangeModal = false;
@@ -518,7 +518,7 @@
</button>
<button
class="modal-button confirm"
on:click={() => handleRoleChange(selectedUser.userId, selectedUser.isAdmin ? 'user' : 'admin', !selectedUser.isAdmin)}
on:click={() => handleRoleChange(selectedUser.userId, !selectedUser.isAdmin)}
>
Change Role
</button>

View File

@@ -25,14 +25,12 @@
try {
loading = true;
const response = await authAPI.getProfile();
console.log('Loaded profile:', response.user);
if (response.user) {
lotwUsername = response.user.lotwUsername || '';
lotwPassword = ''; // Never pre-fill password for security
hasLoTWCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
dclApiKey = response.user.dclApiKey || '';
hasDCLCredentials = !!response.user.dclApiKey;
console.log('Has LoTW credentials:', hasLoTWCredentials, 'Has DCL credentials:', hasDCLCredentials);
}
} catch (err) {
console.error('Failed to load profile:', err);
@@ -50,8 +48,6 @@
error = null;
successLoTW = false;
console.log('Saving LoTW credentials:', { lotwUsername, hasPassword: !!lotwPassword });
await authAPI.updateLoTWCredentials({
lotwUsername,
lotwPassword
@@ -78,8 +74,6 @@
error = null;
successDCL = false;
console.log('Saving DCL credentials:', { hasApiKey: !!dclApiKey });
await authAPI.updateDCLCredentials({
dclApiKey
});