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
This commit is contained in:
curo1305
2026-05-31 15:21:23 +02:00
parent 839bfe0ffe
commit 0647e6e9bf
2 changed files with 197 additions and 15 deletions
+96 -2
View File
@@ -377,17 +377,111 @@ export function updateMyPreferences(payload) {
// ── Audit Log ───────────────────────────────────────────────────────────────── // ── 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() const params = new URLSearchParams()
if (start) params.set('start', start) if (start) params.set('start', start)
if (end) params.set('end', end) 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) if (event_type) params.set('event_type', event_type)
params.set('page', page) params.set('page', page)
params.set('per_page', per_page) params.set('per_page', per_page)
return request(`/api/admin/audit-log?${params}`) 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 ──────────────────────────────────────────────── // ── Document content proxy URL ────────────────────────────────────────────────
export function getDocumentContentUrl(docId) { export function getDocumentContentUrl(docId) {
+101 -13
View File
@@ -19,9 +19,9 @@
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-500 mb-1">User</label> <label class="block text-xs font-semibold text-gray-500 mb-1">User handle</label>
<input <input
v-model="filters.user_id" v-model="filters.user_handle"
type="text" type="text"
placeholder="All users" 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" 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"
@@ -49,10 +49,16 @@
</button> </button>
<button <button
@click="exportCsv" @click="exportCsv"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors" :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"
> >
Export CSV <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> </button>
<p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
@@ -119,6 +125,44 @@
Next Next
</button> </button>
</div> </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> </div>
</template> </template>
@@ -131,16 +175,26 @@ const total = ref(0)
const page = ref(1) const page = ref(1)
const perPage = 50 const perPage = 50
const loading = ref(false) 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({ const filters = reactive({
start: '', start: '',
end: '', end: '',
user_id: '', user_handle: '',
event_type: '', event_type: '',
}) })
onMounted(() => { onMounted(() => {
fetchLog() fetchLog()
loadDailyExports()
}) })
async function fetchLog() { async function fetchLog() {
@@ -149,7 +203,7 @@ async function fetchLog() {
const data = await api.adminListAuditLog({ const data = await api.adminListAuditLog({
start: filters.start || undefined, start: filters.start || undefined,
end: filters.end || undefined, end: filters.end || undefined,
user_id: filters.user_id || undefined, user_handle: filters.user_handle || undefined,
event_type: filters.event_type || undefined, event_type: filters.event_type || undefined,
page: page.value, page: page.value,
per_page: perPage, per_page: perPage,
@@ -182,13 +236,47 @@ function nextPage() {
} }
} }
function exportCsv() { async function exportCsv() {
const params = new URLSearchParams({ format: 'csv' }) exportingCsv.value = true
if (filters.start) params.set('start', filters.start) exportError.value = null
if (filters.end) params.set('end', filters.end) try {
if (filters.user_id) params.set('user_id', filters.user_id) await api.adminExportAuditLogCsv({
if (filters.event_type) params.set('event_type', filters.event_type) start: filters.start || undefined,
window.location.href = `/api/admin/audit-log/export?${params}` 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) { function formatTimestamp(iso) {