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:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user