Add audit log tab to admin customer detail page

Paginated audit log with action color coding, expandable detail rows
showing before/after JSON changes, and relative time formatting.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 20:37:20 -05:00
parent dd558d5dcc
commit 71927d59f9
2 changed files with 311 additions and 20 deletions

View File

@@ -97,10 +97,16 @@ class CustomerController extends Controller
->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']); ->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']);
$auditLogs = AuditLog::query() $auditLogs = AuditLog::query()
->where('user_id', $user->id) ->where(function ($q) use ($user): void {
$q->where('user_id', $user->id)
->orWhere(function ($q2) use ($user): void {
$q2->where('resource_type', 'user')
->where('resource_id', $user->id);
});
})
->with(['user:id,name,email', 'admin:id,name,email'])
->latest() ->latest()
->limit(20) ->paginate(15, ['*'], 'audit_page');
->get(['id', 'action', 'resource_type', 'resource_id', 'ip_address', 'created_at']);
return Inertia::render('Admin/Customers/Show', [ return Inertia::render('Admin/Customers/Show', [
'customer' => $user, 'customer' => $user,

View File

@@ -3,6 +3,7 @@ import { Link, router, useForm } from '@inertiajs/vue3'
import { ref } from 'vue' import { ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue' import AdminLayout from '@/Layouts/AdminLayout.vue'
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers' import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
import type { AuditLog, PaginatedResponse } from '@/types'
interface CustomerProfile { interface CustomerProfile {
billing_address_line1: string | null billing_address_line1: string | null
@@ -71,20 +72,11 @@ interface CustomerInvoice {
created_at: string created_at: string
} }
interface CustomerAuditLog {
id: number
action: string
resource_type: string
resource_id: number | null
ip_address: string | null
created_at: string
}
interface Props { interface Props {
customer: Customer customer: Customer
subscriptions: CustomerSubscription[] subscriptions: CustomerSubscription[]
recentInvoices: CustomerInvoice[] recentInvoices: CustomerInvoice[]
auditLogs: CustomerAuditLog[] auditLogs: PaginatedResponse<AuditLog>
} }
defineOptions({ layout: AdminLayout }) defineOptions({ layout: AdminLayout })
@@ -95,6 +87,7 @@ const activeTab = ref<string>('overview')
const suspendForm = useForm({}) const suspendForm = useForm({})
const unsuspendForm = useForm({}) const unsuspendForm = useForm({})
const expandedRows = ref<Set<number>>(new Set())
function handleSuspend(): void { function handleSuspend(): void {
suspendForm.post(`/customers/${props.customer.id}/suspend`, { suspendForm.post(`/customers/${props.customer.id}/suspend`, {
@@ -164,6 +157,122 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
].filter(Boolean) ].filter(Boolean)
return parts.length > 0 ? parts.join('\n') : 'No billing address on file' return parts.length > 0 ? parts.join('\n') : 'No billing address on file'
} }
function resolveActionColor(action: string): string {
if (action.startsWith('login') || action === 'login') {
return 'info'
}
if (action.includes('impersonate')) {
return 'warning'
}
if (action.startsWith('create') || action === 'register') {
return 'success'
}
if (action.startsWith('update') || action.startsWith('edit') || action.includes('updated') || action.includes('changed')) {
return 'primary'
}
if (action.startsWith('delete') || action.startsWith('terminate') || action.startsWith('destroy')) {
return 'error'
}
if (action.startsWith('suspend')) {
return 'warning'
}
if (action.startsWith('unsuspend')) {
return 'success'
}
return 'secondary'
}
function resolveActionIcon(action: string): string {
if (action.startsWith('login') || action === 'login') {
return 'tabler-login'
}
if (action.includes('impersonate')) {
return 'tabler-user-shield'
}
if (action.startsWith('create') || action === 'register') {
return 'tabler-plus'
}
if (action.startsWith('update') || action.startsWith('edit') || action.includes('updated') || action.includes('changed')) {
return 'tabler-pencil'
}
if (action.startsWith('delete') || action.startsWith('terminate') || action.startsWith('destroy')) {
return 'tabler-trash'
}
if (action.startsWith('suspend')) {
return 'tabler-ban'
}
if (action.startsWith('unsuspend')) {
return 'tabler-circle-check'
}
return 'tabler-activity'
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffSeconds < 60) {
return 'just now'
}
if (diffMinutes < 60) {
return `${diffMinutes}m ago`
}
if (diffHours < 24) {
return `${diffHours}h ago`
}
if (diffDays < 7) {
return `${diffDays}d ago`
}
return formatDate(dateStr)
}
function formatDateTime(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function toggleRow(id: number): void {
if (expandedRows.value.has(id)) {
expandedRows.value.delete(id)
}
else {
expandedRows.value.add(id)
}
}
function isExpanded(id: number): boolean {
return expandedRows.value.has(id)
}
function hasChanges(log: AuditLog): boolean {
return log.changes !== null && Object.keys(log.changes).length > 0
}
function formatJson(changes: Record<string, unknown> | null): string {
if (!changes) {
return '{}'
}
return JSON.stringify(changes, null, 2)
}
function goToAuditPage(page: number): void {
router.get(`/customers/${props.customer.id}`, { audit_page: page }, {
preserveState: true,
preserveScroll: true,
})
}
</script> </script>
<template> <template>
@@ -294,6 +403,13 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
<VIcon icon="tabler-credit-card" start /> <VIcon icon="tabler-credit-card" start />
Billing Billing
</VTab> </VTab>
<VTab value="audit-log">
<VIcon icon="tabler-history" start />
Audit Log
<VChip size="x-small" color="secondary" variant="tonal" class="ms-2">
{{ auditLogs.total }}
</VChip>
</VTab>
</VTabs> </VTabs>
<!-- Tab Content --> <!-- Tab Content -->
@@ -460,11 +576,11 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
<span>Recent Activity</span> <span>Recent Activity</span>
</div> </div>
<VChip size="small" color="primary" variant="tonal"> <VChip size="small" color="primary" variant="tonal">
Last 20 {{ auditLogs.total }} total
</VChip> </VChip>
</VCardTitle> </VCardTitle>
<VCardText v-if="auditLogs.length === 0" class="text-center py-8"> <VCardText v-if="auditLogs.data.length === 0" class="text-center py-8">
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" /> <VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis"> <div class="text-medium-emphasis">
No activity recorded yet. No activity recorded yet.
@@ -481,12 +597,19 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="log in auditLogs" :key="log.id"> <tr v-for="log in auditLogs.data.slice(0, 5)" :key="log.id">
<td class="text-body-2 font-weight-medium"> <td>
<VChip
:color="resolveActionColor(log.action)"
size="small"
variant="tonal"
>
<VIcon :icon="resolveActionIcon(log.action)" start size="14" />
{{ formatAction(log.action) }} {{ formatAction(log.action) }}
</VChip>
</td> </td>
<td class="text-body-2"> <td class="text-body-2">
<span class="text-capitalize">{{ log.resource_type }}</span> <span class="text-capitalize">{{ log.resource_type ?? '-' }}</span>
<span v-if="log.resource_id" class="text-medium-emphasis"> <span v-if="log.resource_id" class="text-medium-emphasis">
#{{ log.resource_id }} #{{ log.resource_id }}
</span> </span>
@@ -495,11 +618,23 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
{{ log.ip_address ?? 'N/A' }} {{ log.ip_address ?? 'N/A' }}
</td> </td>
<td class="text-body-2"> <td class="text-body-2">
{{ formatDate(log.created_at) }} {{ formatRelativeTime(log.created_at) }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
</VTable> </VTable>
<VCardText v-if="auditLogs.data.length > 5" class="text-center pt-2">
<VBtn
variant="text"
color="primary"
size="small"
@click="activeTab = 'audit-log'"
>
View all audit logs
<VIcon icon="tabler-arrow-right" end />
</VBtn>
</VCardText>
</VCard> </VCard>
</VCol> </VCol>
</VRow> </VRow>
@@ -708,6 +843,156 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
</VCol> </VCol>
</VRow> </VRow>
</VWindowItem> </VWindowItem>
<!-- Audit Log Tab -->
<VWindowItem value="audit-log">
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<VIcon icon="tabler-history" size="22" />
<span>Audit Log</span>
</div>
<VChip size="small" color="primary" variant="tonal">
{{ auditLogs.total }} entries
</VChip>
</VCardTitle>
<VCardText v-if="auditLogs.data.length === 0" class="text-center py-12">
<VIcon icon="tabler-clipboard-off" size="48" color="disabled" class="mb-2" />
<div class="text-h6 text-medium-emphasis mb-1">
No audit log entries for this customer
</div>
<div class="text-body-2 text-disabled">
Activity will appear here as it occurs.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th style="width: 40px;" />
<th>Date</th>
<th>Action</th>
<th>Performed By</th>
<th>IP Address</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<template v-for="log in auditLogs.data" :key="log.id">
<tr
:class="{ 'cursor-pointer': hasChanges(log) }"
@click="hasChanges(log) ? toggleRow(log.id) : undefined"
>
<td>
<VBtn
v-if="hasChanges(log)"
variant="text"
size="x-small"
:icon="isExpanded(log.id) ? 'tabler-chevron-down' : 'tabler-chevron-right'"
@click.stop="toggleRow(log.id)"
/>
</td>
<td class="text-body-2">
<div>{{ formatRelativeTime(log.created_at) }}</div>
<div class="text-caption text-medium-emphasis">
{{ formatDateTime(log.created_at) }}
</div>
</td>
<td>
<VChip
:color="resolveActionColor(log.action)"
size="small"
variant="tonal"
>
<VIcon :icon="resolveActionIcon(log.action)" start size="14" />
{{ formatAction(log.action) }}
</VChip>
</td>
<td>
<div v-if="log.admin" class="d-flex align-center gap-2">
<VAvatar color="warning" variant="tonal" size="30">
<span class="text-caption font-weight-medium">
{{ log.admin.name.charAt(0).toUpperCase() }}
</span>
</VAvatar>
<div>
<div class="text-body-2 font-weight-medium">
{{ log.admin.name }}
</div>
<div class="text-caption text-medium-emphasis">
Admin
</div>
</div>
</div>
<div v-else-if="log.user" class="d-flex align-center gap-2">
<VAvatar color="primary" variant="tonal" size="30">
<span class="text-caption font-weight-medium">
{{ log.user.name.charAt(0).toUpperCase() }}
</span>
</VAvatar>
<div>
<div class="text-body-2 font-weight-medium">
{{ log.user.name }}
</div>
<div class="text-caption text-medium-emphasis">
{{ log.user.email }}
</div>
</div>
</div>
<VChip v-else size="small" variant="tonal" color="secondary">
System
</VChip>
</td>
<td class="text-body-2 text-medium-emphasis">
{{ log.ip_address ?? '-' }}
</td>
<td class="text-body-2">
<span v-if="log.resource_type" class="text-capitalize">
{{ log.resource_type }}
</span>
<span v-if="log.resource_id" class="text-medium-emphasis">
#{{ log.resource_id }}
</span>
<VChip
v-if="hasChanges(log)"
size="x-small"
variant="tonal"
color="info"
class="ms-2"
>
{{ Object.keys(log.changes!).length }} fields
</VChip>
<span v-if="!log.resource_type && !hasChanges(log)" class="text-medium-emphasis">-</span>
</td>
</tr>
<!-- Expanded row: changes JSON -->
<tr v-if="isExpanded(log.id) && hasChanges(log)">
<td colspan="6" class="pa-0">
<div class="pa-4 bg-surface-variant">
<div class="text-caption font-weight-semibold mb-2">
Changes
</div>
<pre class="text-caption" style="white-space: pre-wrap; word-break: break-all;">{{ formatJson(log.changes) }}</pre>
</div>
</td>
</tr>
</template>
</tbody>
</VTable>
<!-- Pagination -->
<VCardText v-if="auditLogs.last_page > 1" class="d-flex align-center justify-center pt-4">
<VPagination
:model-value="Math.ceil((auditLogs.from || 1) / 15)"
:length="auditLogs.last_page"
:total-visible="7"
rounded
@update:model-value="goToAuditPage"
/>
</VCardText>
</VCard>
</VWindowItem>
</VWindow> </VWindow>
</div> </div>
</template> </template>