diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 2affa11..6368be9 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -377,17 +377,111 @@ export function updateMyPreferences(payload) { // ── Audit Log ───────────────────────────────────────────────────────────────── -export function adminListAuditLog({ start, end, user_id, event_type, page = 1, per_page = 50 } = {}) { +export function adminListAuditLog({ start, end, user_handle, event_type, page = 1, per_page = 50 } = {}) { const params = new URLSearchParams() if (start) params.set('start', start) if (end) params.set('end', end) - if (user_id) params.set('user_id', user_id) + if (user_handle) params.set('user_handle', user_handle) if (event_type) params.set('event_type', event_type) params.set('page', page) params.set('per_page', per_page) return request(`/api/admin/audit-log?${params}`) } +/** + * Export the audit log as a CSV file using fetch + Blob URL. + * + * Unlike window.location.href, this sends the Authorization Bearer header so + * the endpoint can authenticate the request (D-13, T-06.2-04-03). + * Must NOT call res.json() — CSV is text/csv (Pitfall 5). + */ +export async function adminExportAuditLogCsv(params = {}) { + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const searchParams = new URLSearchParams({ format: 'csv' }) + if (params.start) searchParams.set('start', params.start) + if (params.end) searchParams.set('end', params.end) + if (params.user_handle) searchParams.set('user_handle', params.user_handle) + if (params.event_type) searchParams.set('event_type', params.event_type) + + const headers = {} + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch(`/api/admin/audit-log/export?${searchParams}`, { + headers, + credentials: 'include', + }) + if (!res.ok) throw new Error(`Export failed: ${res.status}`) + + const text = await res.text() + const blob = new Blob([text], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'audit-export.csv' + a.click() + URL.revokeObjectURL(url) +} + +/** + * List available Celery daily audit export files from the MinIO audit-logs bucket. + * + * Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] } + * Items are sorted descending by date. + */ +export async function adminListDailyExports() { + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const headers = {} + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch('/api/admin/audit-log/daily-exports', { + headers, + credentials: 'include', + }) + if (!res.ok) throw new Error(`Failed to list daily exports: ${res.status}`) + return res.json() +} + +/** + * Download a specific Celery daily audit export file from MinIO using fetch + Blob URL. + * + * Uses the same fetch+Blob pattern as adminExportAuditLogCsv to send the + * Authorization Bearer header (D-17, T-06.2-04-03). + * + * @param {string} date — YYYY-MM-DD format date string + */ +export async function adminDownloadDailyExport(date) { + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const headers = {} + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch(`/api/admin/audit-log/daily-exports/${date}`, { + headers, + credentials: 'include', + }) + if (!res.ok) throw new Error(`Download failed: ${res.status}`) + + const text = await res.text() + const blob = new Blob([text], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `audit-${date}.csv` + a.click() + URL.revokeObjectURL(url) +} + // ── Document content proxy URL ──────────────────────────────────────────────── export function getDocumentContentUrl(docId) { diff --git a/frontend/src/components/admin/AuditLogTab.vue b/frontend/src/components/admin/AuditLogTab.vue index 95fedea..31054cb 100644 --- a/frontend/src/components/admin/AuditLogTab.vue +++ b/frontend/src/components/admin/AuditLogTab.vue @@ -19,9 +19,9 @@ />
- + +

{{ exportError }}

@@ -119,6 +125,44 @@ Next + + +
+

Daily exports

+ +

Loading exports…

+ +

+ No daily exports available. +

+ +
+ + + +
+ +

{{ exportsError }}

+
@@ -131,16 +175,26 @@ 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_id: '', + user_handle: '', event_type: '', }) onMounted(() => { fetchLog() + loadDailyExports() }) async function fetchLog() { @@ -149,7 +203,7 @@ async function fetchLog() { const data = await api.adminListAuditLog({ start: filters.start || undefined, end: filters.end || undefined, - user_id: filters.user_id || undefined, + user_handle: filters.user_handle || undefined, event_type: filters.event_type || undefined, page: page.value, per_page: perPage, @@ -182,13 +236,47 @@ function nextPage() { } } -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}` +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) {