From bdd8aa497d9731262a7f0bf7bcbdc42a6a25671e Mon Sep 17 00:00:00 2001 From: Joerg Date: Wed, 21 Jan 2026 18:26:20 +0100 Subject: [PATCH] fix: admin action log and impersonation improvements - Fix admin action log not displaying entries (use raw sqlite for self-join) - Add global impersonation banner to all pages during impersonation - Fix timestamp display in action log (convert Unix seconds to milliseconds) - Add loginWithToken method to auth store for direct token authentication - Fix /api/auth/me to include impersonatedBy field from JWT - Remove duplicate impersonation code from admin page Co-Authored-By: Claude --- src/backend/index.js | 9 +- src/backend/services/admin.service.js | 84 +++++++++-------- src/frontend/src/lib/stores.js | 9 ++ src/frontend/src/routes/+layout.svelte | 95 +++++++++++++++++++ src/frontend/src/routes/admin/+page.svelte | 104 +++------------------ 5 files changed, 171 insertions(+), 130 deletions(-) diff --git a/src/backend/index.js b/src/backend/index.js index 7cfc1ad..6bc1bda 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -16,6 +16,7 @@ import { import { getSystemStats, getUserStats, + getAdminActions, impersonateUser, verifyImpersonation, stopImpersonation, @@ -434,9 +435,15 @@ const app = new Elysia() return { success: false, error: 'User not found' }; } + // Include impersonatedBy from JWT if present (not stored in database) + const responseUser = { + ...userData, + impersonatedBy: user.impersonatedBy, + }; + return { success: true, - user: userData, + user: responseUser, }; }) diff --git a/src/backend/services/admin.service.js b/src/backend/services/admin.service.js index 78db10d..70d54c3 100644 --- a/src/backend/services/admin.service.js +++ b/src/backend/services/admin.service.js @@ -34,31 +34,35 @@ export async function logAdminAction(adminId, actionType, targetUserId = null, d * @returns {Promise} Array of admin actions */ export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) { - let query = db - .select({ - id: adminActions.id, - adminId: adminActions.adminId, - adminEmail: users.email, - adminCallsign: users.callsign, - actionType: adminActions.actionType, - targetUserId: adminActions.targetUserId, - targetEmail: sql`target_users.email`.as('targetEmail'), - targetCallsign: sql`target_users.callsign`.as('targetCallsign'), - details: adminActions.details, - createdAt: adminActions.createdAt, - }) - .from(adminActions) - .leftJoin(users, eq(adminActions.adminId, users.id)) - .leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id'))) - .orderBy(desc(adminActions.createdAt)) - .limit(limit) - .offset(offset); + // Use raw SQL for the self-join (admin users and target users from same users table) + // Using bun:sqlite prepared statements for raw SQL + let query = ` + SELECT + aa.id as id, + aa.admin_id as adminId, + admin_user.email as adminEmail, + admin_user.callsign as adminCallsign, + aa.action_type as actionType, + aa.target_user_id as targetUserId, + target_user.email as targetEmail, + target_user.callsign as targetCallsign, + aa.details as details, + aa.created_at as createdAt + FROM admin_actions aa + LEFT JOIN users admin_user ON admin_user.id = aa.admin_id + LEFT JOIN users target_user ON target_user.id = aa.target_user_id + `; - if (adminId) { - query = query.where(eq(adminActions.adminId, adminId)); + const params = []; + if (adminId !== null) { + query += ` WHERE aa.admin_id = ?`; + params.push(adminId); } - return await query; + query += ` ORDER BY aa.created_at DESC LIMIT ? OFFSET ?`; + params.push(limit, offset); + + return sqlite.prepare(query).all(...params); } /** @@ -237,24 +241,26 @@ export async function stopImpersonation(adminId, targetUserId) { * @returns {Promise} Array of recent impersonation actions */ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) { - const impersonations = await db - .select({ - id: adminActions.id, - actionType: adminActions.actionType, - targetUserId: adminActions.targetUserId, - targetEmail: sql`target_users.email`, - targetCallsign: sql`target_users.callsign`, - details: adminActions.details, - createdAt: adminActions.createdAt, - }) - .from(adminActions) - .leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id'))) - .where(eq(adminActions.adminId, adminId)) - .where(sql`${adminActions.actionType} LIKE 'impersonate%'`) - .orderBy(desc(adminActions.createdAt)) - .limit(limit); + // Use raw SQL for the self-join to avoid Drizzle alias issues + // Using bun:sqlite prepared statements for raw SQL + const query = ` + SELECT + aa.id as id, + aa.action_type as actionType, + aa.target_user_id as targetUserId, + u.email as targetEmail, + u.callsign as targetCallsign, + aa.details as details, + aa.created_at as createdAt + FROM admin_actions aa + LEFT JOIN users u ON u.id = aa.target_user_id + WHERE aa.admin_id = ? + AND aa.action_type LIKE 'impersonate%' + ORDER BY aa.created_at DESC + LIMIT ? + `; - return impersonations; + return sqlite.prepare(query).all(adminId, limit); } /** diff --git a/src/frontend/src/lib/stores.js b/src/frontend/src/lib/stores.js index 20ceb06..bfad759 100644 --- a/src/frontend/src/lib/stores.js +++ b/src/frontend/src/lib/stores.js @@ -103,6 +103,15 @@ function createAuthStore() { clearError: () => { update((s) => ({ ...s, error: null })); }, + + // Direct login with user object and token (for impersonation) + loginWithToken: (user, token) => { + if (browser) { + localStorage.setItem('auth_token', token); + localStorage.setItem('auth_user', JSON.stringify(user)); + } + set({ user, token, loading: false, error: null }); + }, }; } diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte index 96a1573..40626f8 100644 --- a/src/frontend/src/routes/+layout.svelte +++ b/src/frontend/src/routes/+layout.svelte @@ -2,6 +2,9 @@ import { browser } from '$app/environment'; import { auth } from '$lib/stores.js'; import { goto } from '$app/navigation'; + import { adminAPI, authAPI } from '$lib/api.js'; + + let stoppingImpersonation = false; function handleLogout() { auth.logout(); @@ -11,6 +14,31 @@ window.location.href = '/auth/login'; } } + + async function handleStopImpersonation() { + if (stoppingImpersonation) return; + + try { + stoppingImpersonation = true; + const data = await adminAPI.stopImpersonation(); + + if (data.success) { + // Update auth store with admin user data and new token + auth.loginWithToken(data.user, data.token); + + // Hard redirect to home page + if (browser) { + window.location.href = '/'; + } + } else { + alert('Failed to stop impersonation: ' + (data.error || 'Unknown error')); + } + } catch (err) { + alert('Failed to stop impersonation: ' + err.message); + } finally { + stoppingImpersonation = false; + } + } @@ -39,6 +67,26 @@ {/if} + + + {#if $auth.user?.impersonatedBy} +
+
+ ⚠️ + + You are currently impersonating {$auth.user.email} + + +
+
+ {/if} +
@@ -156,4 +204,51 @@ margin: 0; font-size: 0.875rem; } + + /* Impersonation Banner */ + .impersonation-banner { + background-color: #fff3cd; + border: 2px solid #ffc107; + padding: 0.75rem 1rem; + } + + .impersonation-content { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + } + + .warning-icon { + font-size: 1.25rem; + } + + .impersonation-text { + flex: 1; + font-size: 0.95rem; + color: #856404; + } + + .stop-impersonation-btn { + background-color: #ffc107; + color: #000; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + transition: background-color 0.2s; + } + + .stop-impersonation-btn:hover:not(:disabled) { + background-color: #e0a800; + } + + .stop-impersonation-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } diff --git a/src/frontend/src/routes/admin/+page.svelte b/src/frontend/src/routes/admin/+page.svelte index 0a029a7..06b7fb1 100644 --- a/src/frontend/src/routes/admin/+page.svelte +++ b/src/frontend/src/routes/admin/+page.svelte @@ -1,7 +1,7 @@