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:
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user