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:
@@ -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,
|
||||
};
|
||||
})
|
||||
|
||||
|
||||
@@ -34,31 +34,35 @@ export async function logAdminAction(adminId, actionType, targetUserId = null, d
|
||||
* @returns {Promise<Array>} 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>} 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -39,6 +67,26 @@
|
||||
</div>
|
||||
</nav>
|
||||
{/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>
|
||||
<slot />
|
||||
</main>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores.js';
|
||||
import { adminAPI } from '$lib/api.js';
|
||||
import { adminAPI, authAPI } from '$lib/api.js';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let loading = true;
|
||||
@@ -90,16 +90,16 @@
|
||||
const data = await adminAPI.impersonate(userId);
|
||||
|
||||
if (data.success) {
|
||||
// Store new token
|
||||
// Store the new impersonation token
|
||||
if (browser) {
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
}
|
||||
|
||||
// Update auth store with new user data
|
||||
auth.login({
|
||||
...data.impersonating,
|
||||
impersonatedBy: $auth.user.id,
|
||||
});
|
||||
// Fetch the full user profile (which includes impersonatedBy)
|
||||
const profileData = await authAPI.getProfile();
|
||||
|
||||
// Update auth store with complete user data
|
||||
auth.loginWithToken(profileData.user, data.token);
|
||||
|
||||
// Redirect to home page
|
||||
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) {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
@@ -203,7 +177,11 @@
|
||||
|
||||
function formatDate(dateString) {
|
||||
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',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -232,21 +210,6 @@
|
||||
<div class="error">{error}</div>
|
||||
{:else}
|
||||
<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>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
@@ -573,45 +536,6 @@
|
||||
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 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
@@ -863,12 +787,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-type.impostor_start {
|
||||
.action-type.impersonate_start {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.action-type.impostor_stop {
|
||||
.action-type.impersonate_stop {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user