feat(phase-4-09): create new components — FolderRow, FolderBreadcrumb, FolderDeleteModal, ShareModal, DocumentPreviewModal, SearchBar, SortControls, AuditLogTab
- FolderRow: inline rename, three-dot menu, delete/rename callbacks, outside-click close - FolderBreadcrumb: truncation at depth > 4, nav aria-label, ol structure - FolderDeleteModal: role=dialog, warning icon, doc count in body, Keep/Delete buttons - ShareModal: handle input, recipients list with revoke, 404/409 error handling - DocumentPreviewModal: iframe with proxy URL only (never presigned), Escape/overlay close - SearchBar: role=search, aria-label, Escape clears - SortControls: aria-pressed, direction indicator, toggle vs switch logic - AuditLogTab: filters, paginated table, CSV export via window.location.href - api/client.js: add adminListAuditLog function
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
<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</label>
|
||||
<input
|
||||
v-model="filters.user_id"
|
||||
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"
|
||||
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</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>
|
||||
</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 filters = reactive({
|
||||
start: '',
|
||||
end: '',
|
||||
user_id: '',
|
||||
event_type: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchLog()
|
||||
})
|
||||
|
||||
async function fetchLog() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.adminListAuditLog({
|
||||
start: filters.start || undefined,
|
||||
end: filters.end || undefined,
|
||||
user_id: filters.user_id || 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()
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const params = new URLSearchParams({ format: 'csv' })
|
||||
if (filters.start) params.set('start', filters.start)
|
||||
if (filters.end) params.set('end', filters.end)
|
||||
if (filters.user_id) params.set('user_id', filters.user_id)
|
||||
if (filters.event_type) params.set('event_type', filters.event_type)
|
||||
window.location.href = `/api/admin/audit-log/export?${params}`
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user