- Normalize page titles to text-2xl font-semibold (was font-bold in TopicsView, DocumentView, SharedView) - Normalize section titles to text-lg font-semibold (was text-xl in SettingsPreferencesTab, SettingsAiTab, SettingsCloudTab, BackupCodesDisplay, CloudCredentialModal) - Normalize panel headings to text-sm font-semibold (SettingsAccountTab, DocumentView) - Normalize AdminOverviewView page title from text-xl to text-2xl font-semibold - Replace decorative sidebar skeleton inline styles with static Tailwind width classes (w-12/w-16/w-20) - Add focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 to all interactive buttons across the app - Add active:bg-* states to primary and secondary buttons for coherent press feedback - Update nav-link scoped CSS in AppSidebar and AdminSidebar to include active:bg-gray-200 and focus-visible ring - Add 7 new tests: VISUAL-01 skeleton class invariant, VISUAL-04 focus-visible invariant, VISUAL-03 typography invariant (36 test files, 270 tests pass)
347 lines
13 KiB
Vue
347 lines
13 KiB
Vue
<template>
|
|
<div>
|
|
<BreadcrumbBar :segments="[{ label: 'Audit Log' }]" :show-root="false" class="mb-4" />
|
|
|
|
<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 active:bg-indigo-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
>
|
|
Apply filters
|
|
</button>
|
|
<button
|
|
v-if="activeFilterCount > 0"
|
|
@click="clearFilters"
|
|
class="border border-gray-300 text-gray-500 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
<div class="relative inline-flex flex-col items-start gap-1">
|
|
<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 active:bg-gray-100 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
>
|
|
<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>
|
|
<span
|
|
v-if="activeFilterCount > 0"
|
|
class="text-xs text-amber-600"
|
|
>
|
|
{{ activeFilterCount }} filter{{ activeFilterCount !== 1 ? 's' : '' }} active
|
|
</span>
|
|
</div>
|
|
<p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p>
|
|
</div>
|
|
|
|
<p v-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p>
|
|
|
|
<div v-if="!fetchError" 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">Email</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>
|
|
<template v-if="loading">
|
|
<tr v-for="n in 8" :key="`sk-${n}`" class="border-b border-gray-100">
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-32"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-20"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-36"></div></td>
|
|
<td class="px-4 py-3"><div class="h-4 bg-gray-100 rounded-full animate-pulse w-16"></div></td>
|
|
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-24"></div></td>
|
|
</tr>
|
|
</template>
|
|
<tr v-else-if="entries.length === 0">
|
|
<td colspan="5" class="px-0 py-0">
|
|
<EmptyState icon="clipboardList" headline="No entries found" subtext="Try adjusting your filters or date range.">
|
|
<template #cta>
|
|
<button @click="clearFilters" class="mt-3 text-sm text-indigo-600 hover:underline">Clear filters</button>
|
|
</template>
|
|
</EmptyState>
|
|
</td>
|
|
</tr>
|
|
<template v-else>
|
|
<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 text-sm text-gray-500">
|
|
<span v-if="entry.user_email">{{ entry.user_email }}</span>
|
|
<span v-else-if="entry.metadata_?.attempted_email_hash" class="text-amber-600 font-mono text-xs">hash:{{ entry.metadata_.attempted_email_hash }} <span class="text-xs not-italic">(attempted)</span></span>
|
|
<span v-else>—</span>
|
|
</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>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="!loading && !fetchError && 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 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span class="text-sm text-gray-500">Page {{ page }}</span>
|
|
<button
|
|
@click="nextPage"
|
|
:disabled="page * perPage >= total"
|
|
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 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
>
|
|
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 active:bg-indigo-800 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-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, computed } from 'vue'
|
|
import * as api from '../../api/client.js'
|
|
import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'
|
|
import EmptyState from '../../components/ui/EmptyState.vue'
|
|
import { formatTimestamp } from '../../utils/formatters.js'
|
|
|
|
const entries = ref([])
|
|
const total = ref(0)
|
|
const page = ref(1)
|
|
const perPage = 50
|
|
const loading = ref(false)
|
|
const fetchError = ref(null)
|
|
const exportingCsv = ref(false)
|
|
const exportError = ref(null)
|
|
|
|
|
|
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
|
|
fetchError.value = null
|
|
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 = []
|
|
fetchError.value = 'Failed to load audit log. Please try again.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function applyFilters() {
|
|
page.value = 1
|
|
fetchLog()
|
|
}
|
|
|
|
function clearFilters() {
|
|
filters.start = ''
|
|
filters.end = ''
|
|
filters.user_handle = ''
|
|
filters.event_type = ''
|
|
page.value = 1
|
|
fetchLog()
|
|
}
|
|
|
|
const activeFilterCount = computed(() => {
|
|
let count = 0
|
|
if (filters.start) count++
|
|
if (filters.end) count++
|
|
if (filters.user_handle) count++
|
|
if (filters.event_type) count++
|
|
return count
|
|
})
|
|
|
|
function prevPage() {
|
|
if (page.value > 1) {
|
|
page.value--
|
|
fetchLog()
|
|
}
|
|
}
|
|
|
|
function nextPage() {
|
|
if (page.value * perPage < total.value) {
|
|
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 = []
|
|
exportsError.value = 'Failed to load daily exports. Please try again.'
|
|
} 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 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>
|