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 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 18:26:20 +01:00
parent 7c209e3270
commit bdd8aa497d
5 changed files with 171 additions and 130 deletions

View File

@@ -16,6 +16,7 @@ import {
import { import {
getSystemStats, getSystemStats,
getUserStats, getUserStats,
getAdminActions,
impersonateUser, impersonateUser,
verifyImpersonation, verifyImpersonation,
stopImpersonation, stopImpersonation,
@@ -434,9 +435,15 @@ const app = new Elysia()
return { success: false, error: 'User not found' }; return { success: false, error: 'User not found' };
} }
// Include impersonatedBy from JWT if present (not stored in database)
const responseUser = {
...userData,
impersonatedBy: user.impersonatedBy,
};
return { return {
success: true, success: true,
user: userData, user: responseUser,
}; };
}) })

View File

@@ -34,31 +34,35 @@ export async function logAdminAction(adminId, actionType, targetUserId = null, d
* @returns {Promise<Array>} Array of admin actions * @returns {Promise<Array>} Array of admin actions
*/ */
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) { export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
let query = db // Use raw SQL for the self-join (admin users and target users from same users table)
.select({ // Using bun:sqlite prepared statements for raw SQL
id: adminActions.id, let query = `
adminId: adminActions.adminId, SELECT
adminEmail: users.email, aa.id as id,
adminCallsign: users.callsign, aa.admin_id as adminId,
actionType: adminActions.actionType, admin_user.email as adminEmail,
targetUserId: adminActions.targetUserId, admin_user.callsign as adminCallsign,
targetEmail: sql`target_users.email`.as('targetEmail'), aa.action_type as actionType,
targetCallsign: sql`target_users.callsign`.as('targetCallsign'), aa.target_user_id as targetUserId,
details: adminActions.details, target_user.email as targetEmail,
createdAt: adminActions.createdAt, target_user.callsign as targetCallsign,
}) aa.details as details,
.from(adminActions) aa.created_at as createdAt
.leftJoin(users, eq(adminActions.adminId, users.id)) FROM admin_actions aa
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id'))) LEFT JOIN users admin_user ON admin_user.id = aa.admin_id
.orderBy(desc(adminActions.createdAt)) LEFT JOIN users target_user ON target_user.id = aa.target_user_id
.limit(limit) `;
.offset(offset);
if (adminId) { const params = [];
query = query.where(eq(adminActions.adminId, adminId)); 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>} Array of recent impersonation actions * @returns {Promise<Array>} Array of recent impersonation actions
*/ */
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) { export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
const impersonations = await db // Use raw SQL for the self-join to avoid Drizzle alias issues
.select({ // Using bun:sqlite prepared statements for raw SQL
id: adminActions.id, const query = `
actionType: adminActions.actionType, SELECT
targetUserId: adminActions.targetUserId, aa.id as id,
targetEmail: sql`target_users.email`, aa.action_type as actionType,
targetCallsign: sql`target_users.callsign`, aa.target_user_id as targetUserId,
details: adminActions.details, u.email as targetEmail,
createdAt: adminActions.createdAt, u.callsign as targetCallsign,
}) aa.details as details,
.from(adminActions) aa.created_at as createdAt
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id'))) FROM admin_actions aa
.where(eq(adminActions.adminId, adminId)) LEFT JOIN users u ON u.id = aa.target_user_id
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`) WHERE aa.admin_id = ?
.orderBy(desc(adminActions.createdAt)) AND aa.action_type LIKE 'impersonate%'
.limit(limit); ORDER BY aa.created_at DESC
LIMIT ?
`;
return impersonations; return sqlite.prepare(query).all(adminId, limit);
} }
/** /**

View File

@@ -103,6 +103,15 @@ function createAuthStore() {
clearError: () => { clearError: () => {
update((s) => ({ ...s, error: null })); 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 });
},
}; };
} }

View File

@@ -2,6 +2,9 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { adminAPI, authAPI } from '$lib/api.js';
let stoppingImpersonation = false;
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
@@ -11,6 +14,31 @@
window.location.href = '/auth/login'; 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;
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -39,6 +67,26 @@
</div> </div>
</nav> </nav>
{/if} {/if}
<!-- Impersonation Banner -->
{#if $auth.user?.impersonatedBy}
<div class="impersonation-banner">
<div class="impersonation-content">
<span class="warning-icon">⚠️</span>
<span class="impersonation-text">
You are currently impersonating <strong>{$auth.user.email}</strong>
</span>
<button
class="stop-impersonation-btn"
on:click={handleStopImpersonation}
disabled={stoppingImpersonation}
>
{stoppingImpersonation ? 'Stopping...' : 'Stop Impersonation'}
</button>
</div>
</div>
{/if}
<main> <main>
<slot /> <slot />
</main> </main>
@@ -156,4 +204,51 @@
margin: 0; margin: 0;
font-size: 0.875rem; 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;
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { adminAPI } from '$lib/api.js'; import { adminAPI, authAPI } from '$lib/api.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
let loading = true; let loading = true;
@@ -90,16 +90,16 @@
const data = await adminAPI.impersonate(userId); const data = await adminAPI.impersonate(userId);
if (data.success) { if (data.success) {
// Store new token // Store the new impersonation token
if (browser) { if (browser) {
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
} }
// Update auth store with new user data // Fetch the full user profile (which includes impersonatedBy)
auth.login({ const profileData = await authAPI.getProfile();
...data.impersonating,
impersonatedBy: $auth.user.id, // Update auth store with complete user data
}); auth.loginWithToken(profileData.user, data.token);
// Redirect to home page // Redirect to home page
window.location.href = '/'; window.location.href = '/';
@@ -114,32 +114,6 @@
} }
} }
async function handleStopImpersonation() {
try {
loading = true;
const data = await adminAPI.stopImpersonation();
if (data.success) {
// Store admin token
if (browser) {
localStorage.setItem('auth_token', data.token);
}
// Update auth store
auth.login(data.user);
alert(data.message);
window.location.reload();
} else {
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Failed to stop impersonation: ' + err.message);
} finally {
loading = false;
}
}
async function handleDeleteUser(userId) { async function handleDeleteUser(userId) {
const user = users.find(u => u.id === userId); const user = users.find(u => u.id === userId);
if (!user) return; if (!user) return;
@@ -203,7 +177,11 @@
function formatDate(dateString) { function formatDate(dateString) {
if (!dateString) return 'N/A'; if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString('en-US', { // Handle Unix timestamps (seconds) by converting to milliseconds
const date = typeof dateString === 'number'
? new Date(dateString * 1000)
: new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -232,21 +210,6 @@
<div class="error">{error}</div> <div class="error">{error}</div>
{:else} {:else}
<div class="admin-dashboard"> <div class="admin-dashboard">
<!-- Impersonation Banner -->
{#if $auth.user?.impersonatedBy}
<div class="impersonation-banner">
<div class="impersonation-content">
<span class="warning-icon">⚠️</span>
<span class="impersonation-text">
You are currently impersonating <strong>{$auth.user.email}</strong>
</span>
<button class="stop-impersonation-btn" on:click={handleStopImpersonation}>
Stop Impersonation
</button>
</div>
</div>
{/if}
<h1>Admin Dashboard</h1> <h1>Admin Dashboard</h1>
<!-- Tab Navigation --> <!-- Tab Navigation -->
@@ -573,45 +536,6 @@
color: #c00; color: #c00;
} }
/* Impersonation Banner */
.impersonation-banner {
background-color: #fff3cd;
border: 2px solid #ffc107;
border-radius: 4px;
padding: 1rem;
margin-bottom: 2rem;
}
.impersonation-content {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.warning-icon {
font-size: 1.5rem;
}
.impersonation-text {
flex: 1;
font-size: 1rem;
}
.stop-impersonation-btn {
background-color: #ffc107;
color: #000;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.stop-impersonation-btn:hover {
background-color: #e0a800;
}
h1 { h1 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: #333; color: #333;
@@ -863,12 +787,12 @@
font-weight: 600; font-weight: 600;
} }
.action-type.impostor_start { .action-type.impersonate_start {
background-color: #ffc107; background-color: #ffc107;
color: #000; color: #000;
} }
.action-type.impostor_stop { .action-type.impersonate_stop {
background-color: #28a745; background-color: #28a745;
color: white; color: white;
} }