Files
kite/frontend/src/components/admin/AuditLogTab.vue
T
curo1305 0647e6e9bf feat(06.2-04): frontend — user_handle filter, fetch+Blob export, daily-export section
- adminListAuditLog: rename user_id param to user_handle (backend API change)
- adminExportAuditLogCsv(): fetch+Blob pattern — sends Bearer header (D-13, T-06.2-04-03)
- adminListDailyExports(): raw fetch returning JSON for daily export listing (D-17)
- adminDownloadDailyExport(date): fetch+Blob download with audit-{date}.csv filename (D-17)
- AuditLogTab: rename filters.user_id to filters.user_handle + label 'User handle' (D-12, C-5)
- AuditLogTab: exportCsv() replaced with async fetch+Blob call, exportingCsv loading state
- AuditLogTab: daily exports section below pagination — date dropdown + Download button (D-17, C-4)
- window.location.href removed from AuditLogTab (broken auth bypass closed)
- Build exits 0, full backend suite: 337 passed, 1 pre-existing failure
2026-05-31 15:21:23 +02:00

301 lines
9.9 KiB
Vue

<template>
<div>
<!-- Filter bar -->
<div class="flex flex-wrap gap-3 mb-4 items-end">
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">From</label>
<input
v-model="filters.start"
type="date"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">To</label>
<input
v-model="filters.end"
type="date"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">User handle</label>
<input
v-model="filters.user_handle"
type="text"
placeholder="All users"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 w-36"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">Action</label>
<select
v-model="filters.event_type"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white"
>
<option value="">All actions</option>
<option value="auth">Auth</option>
<option value="document">Document</option>
<option value="folder">Folder</option>
<option value="share">Share</option>
<option value="admin">Admin</option>
</select>
</div>
<button
@click="applyFilters"
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
Apply filters
</button>
<button
@click="exportCsv"
:disabled="exportingCsv"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<span v-if="exportingCsv" class="flex items-center gap-1">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Exporting
</span>
<span v-else>Export CSV</span>
</button>
<p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Loading audit log
</div>
</div>
<!-- Empty state -->
<div v-else-if="entries.length === 0" class="text-center py-12 text-gray-400 text-sm">
No audit log entries match the selected filters.
</div>
<!-- Table -->
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Timestamp</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">User</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Action Type</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">IP Address</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in entries"
:key="entry.id"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
<td class="px-4 py-3">
<span
class="text-xs px-2 py-1 rounded-full font-medium"
:class="actionTypeClass(entry.event_type)"
>
{{ entry.event_type }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 font-mono text-xs">{{ entry.ip_address || '—' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="!loading && entries.length > 0" class="flex items-center justify-between mt-4">
<button
@click="prevPage"
:disabled="page <= 1"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Previous
</button>
<span class="text-sm text-gray-500">Page {{ page }}</span>
<button
@click="nextPage"
:disabled="entries.length < perPage"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Next
</button>
</div>
<!-- Daily exports section (D-17, C-4) -->
<div class="border-t border-gray-100 mt-6 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Daily exports</h3>
<p v-if="loadingExports" class="text-sm text-gray-400">Loading exports</p>
<p v-else-if="dailyExports.length === 0" class="text-sm text-gray-400 italic">
No daily exports available.
</p>
<div v-else class="flex items-end gap-3">
<select
v-model="selectedExportDate"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white"
>
<option value="" disabled>Choose a date</option>
<option
v-for="exp in dailyExports"
:key="exp.date"
:value="exp.date"
>
{{ exp.date }}
</option>
</select>
<button
@click="downloadDailyExport"
:disabled="!selectedExportDate || downloadingExport"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors flex items-center gap-1"
>
<span v-if="downloadingExport" class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Download
</button>
</div>
<p v-if="exportsError" class="text-xs text-red-600 mt-2">{{ exportsError }}</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import * as api from '../../api/client.js'
const entries = ref([])
const total = ref(0)
const page = ref(1)
const perPage = 50
const loading = ref(false)
const exportingCsv = ref(false)
const exportError = ref(null)
// Daily exports state (D-17)
const dailyExports = ref([])
const loadingExports = ref(false)
const selectedExportDate = ref('')
const downloadingExport = ref(false)
const exportsError = ref(null)
const filters = reactive({
start: '',
end: '',
user_handle: '',
event_type: '',
})
onMounted(() => {
fetchLog()
loadDailyExports()
})
async function fetchLog() {
loading.value = true
try {
const data = await api.adminListAuditLog({
start: filters.start || undefined,
end: filters.end || undefined,
user_handle: filters.user_handle || undefined,
event_type: filters.event_type || undefined,
page: page.value,
per_page: perPage,
})
entries.value = Array.isArray(data) ? data : (data.items ?? [])
total.value = data.total ?? entries.value.length
} catch (e) {
entries.value = []
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
fetchLog()
}
function prevPage() {
if (page.value > 1) {
page.value--
fetchLog()
}
}
function nextPage() {
if (entries.value.length >= perPage) {
page.value++
fetchLog()
}
}
async function exportCsv() {
exportingCsv.value = true
exportError.value = null
try {
await api.adminExportAuditLogCsv({
start: filters.start || undefined,
end: filters.end || undefined,
user_handle: filters.user_handle || undefined,
event_type: filters.event_type || undefined,
})
} catch (e) {
exportError.value = 'Export failed. Please try again.'
} finally {
exportingCsv.value = false
}
}
async function loadDailyExports() {
loadingExports.value = true
exportsError.value = null
try {
const data = await api.adminListDailyExports()
dailyExports.value = data.items ?? []
} catch (e) {
dailyExports.value = []
} finally {
loadingExports.value = false
}
}
async function downloadDailyExport() {
if (!selectedExportDate.value) return
downloadingExport.value = true
exportsError.value = null
try {
await api.adminDownloadDailyExport(selectedExportDate.value)
} catch (e) {
exportsError.value = 'Download failed. Please try again.'
} finally {
downloadingExport.value = false
}
}
function formatTimestamp(iso) {
if (!iso) return '—'
try {
return new Date(iso).toISOString().replace('T', ' ').slice(0, 19)
} catch {
return iso
}
}
function actionTypeClass(eventType) {
if (!eventType) return 'bg-gray-100 text-gray-600'
const t = eventType.toLowerCase()
if (t.startsWith('auth')) return 'bg-blue-50 text-blue-600'
if (t.startsWith('document')) return 'bg-gray-100 text-gray-600'
if (t.startsWith('folder') || t.startsWith('share')) return 'bg-purple-50 text-purple-600'
if (t.startsWith('admin')) return 'bg-amber-50 text-amber-700'
return 'bg-gray-100 text-gray-600'
}
</script>