chore: merge executor worktree (worktree-agent-af66944050628b0e4)
This commit is contained in:
+129
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
phase: "06.2"
|
||||||
|
plan: "04"
|
||||||
|
subsystem: "admin-audit-log"
|
||||||
|
tags: [audit-log, handle-enrichment, csv-export, daily-exports, admin, security]
|
||||||
|
dependency_graph:
|
||||||
|
requires: ["06.2-02", "06.2-03"]
|
||||||
|
provides: ["ADMIN-06 complete", "handle-enriched audit log", "fixed CSV export", "daily export UI"]
|
||||||
|
affects: ["backend/api/audit.py", "frontend/src/components/admin/AuditLogTab.vue", "frontend/src/api/client.js"]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "SQLAlchemy aliased double-JOIN for handle enrichment"
|
||||||
|
- "Handle-to-UUID resolution with empty-result fallback for unknown handles"
|
||||||
|
- "asyncio.to_thread wrapping synchronous MinIO SDK calls"
|
||||||
|
- "fetch+Blob URL pattern for authenticated CSV download"
|
||||||
|
- "Date path parameter regex validation before MinIO key construction"
|
||||||
|
key_files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- backend/api/audit.py
|
||||||
|
- backend/tests/test_audit.py
|
||||||
|
- frontend/src/api/client.js
|
||||||
|
- frontend/src/components/admin/AuditLogTab.vue
|
||||||
|
decisions:
|
||||||
|
- "Module-level import of get_storage_backend and MinIOBackend in audit.py to enable testable patch targets"
|
||||||
|
- "Separate count query (no JOIN) from data query (with JOIN) to avoid COUNT subquery ambiguity on multi-column selects (Pitfall 4)"
|
||||||
|
- "export_audit_log uses same _audit_to_dict_with_handles() as list_audit_log to prevent UUID-only CSV export regression (Pitfall 7)"
|
||||||
|
- "Updated test_audit_log_export_csv expected CSV header to include user_handle and actor_handle columns"
|
||||||
|
metrics:
|
||||||
|
duration: "~25 minutes"
|
||||||
|
completed_date: "2026-05-31"
|
||||||
|
tasks_completed: 2
|
||||||
|
files_modified: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 06.2 Plan 04: ADMIN-06 audit enrichment + CSV + daily exports Summary
|
||||||
|
|
||||||
|
**One-liner:** Handle-enriched audit log (aliased double-JOIN), user_handle filter with handle→UUID resolution, fixed CSV export via fetch+Blob, and new daily-export listing + streaming download endpoints with MinIO integration.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Backend (backend/api/audit.py)
|
||||||
|
|
||||||
|
**New helper functions:**
|
||||||
|
- `_audit_to_dict_with_handles(entry, user_handle, actor_handle)` — extends the existing `_audit_to_dict()` to include `user_handle` and `actor_handle` fields. Used by both the JSON viewer and CSV export (Pitfall 7 compliance).
|
||||||
|
- `_build_filtered_query_with_handles(start, end, user_uuid, event_type)` — builds a multi-column select joining `User` twice (as `UserSubject` and `UserActor` via `aliased()`) to resolve handles. Returns `(AuditLog, user_handle, actor_handle)` row tuples.
|
||||||
|
|
||||||
|
**Updated endpoints:**
|
||||||
|
- `GET /api/admin/audit-log` — `user_id: Optional[uuid.UUID]` replaced with `user_handle: Optional[str]`. Handle resolved to UUID via preliminary SELECT; unknown handles return empty results (not 422). Data query uses enriched JOIN; count query uses plain query to avoid subquery ambiguity (Pitfall 4).
|
||||||
|
- `GET /api/admin/audit-log/export` — same user_handle change; uses `_audit_to_dict_with_handles()` so CSV includes `user_handle` and `actor_handle` columns.
|
||||||
|
|
||||||
|
**New endpoints (registered before existing ones to ensure route priority):**
|
||||||
|
- `GET /api/admin/audit-log/daily-exports` — lists MinIO `audit-logs` bucket via `asyncio.to_thread(_list)`. Returns `{items: [{date, key}]}` sorted descending by date. Returns `{items: []}` if backend is not MinIOBackend.
|
||||||
|
- `GET /api/admin/audit-log/daily-exports/{date}` — validates date against `re.fullmatch(r"\d{4}-\d{2}-\d{2}", date)` before constructing `f"audit-logs/{date}.csv"` key (T-06.2-04-01 path traversal prevention). Streams CSV via `asyncio.to_thread(_get)`. Returns 404 on invalid date or missing file.
|
||||||
|
|
||||||
|
Both new endpoints use `Depends(get_current_admin)` (T-06.2-04-02).
|
||||||
|
|
||||||
|
### Backend Tests (backend/tests/test_audit.py)
|
||||||
|
|
||||||
|
Five xfail stubs promoted to full integration tests:
|
||||||
|
1. `test_audit_log_includes_user_handle` — seeds entry, asserts `user_handle` and `actor_handle` keys present, handle matches admin_user fixture
|
||||||
|
2. `test_audit_log_filter_by_handle` — seeds two users, asserts filtering by handle returns only matching entries
|
||||||
|
3. `test_audit_log_filter_unknown_handle` — asserts 200 + `items==[]` + `total==0` for unknown handle
|
||||||
|
4. `test_daily_exports_list` — mocks `get_storage_backend` with mock MinIO client, asserts sorted `items` returned
|
||||||
|
5. `test_daily_export_download` — mocks `get_object`, asserts `text/csv` Content-Type, `2026-05-30` in Content-Disposition, 404 for invalid date
|
||||||
|
|
||||||
|
Also updated `test_audit_log_export_csv` expected CSV header to include `user_handle,actor_handle` columns — regression caused by enriched export; correct per Pitfall 7.
|
||||||
|
|
||||||
|
**Final test counts:** 10 tests pass (4 pre-existing + 6 updated/promoted), full suite 337 passed / 1 pre-existing failure (test_extract_docx, ModuleNotFoundError — unrelated).
|
||||||
|
|
||||||
|
### Frontend (frontend/src/api/client.js)
|
||||||
|
|
||||||
|
Three new exported functions:
|
||||||
|
- `adminExportAuditLogCsv(params)` — raw `fetch()` with Authorization Bearer header, `res.text()` (not `res.json()`), Blob + `<a>` click download pattern (D-13, T-06.2-04-03)
|
||||||
|
- `adminListDailyExports()` — raw `fetch()` + `res.json()` for the JSON-returning listing endpoint
|
||||||
|
- `adminDownloadDailyExport(date)` — raw `fetch()` with Bearer header, Blob download as `audit-{date}.csv`
|
||||||
|
|
||||||
|
Updated `adminListAuditLog()` — parameter renamed from `user_id` to `user_handle` to match backend API change.
|
||||||
|
|
||||||
|
### Frontend (frontend/src/components/admin/AuditLogTab.vue)
|
||||||
|
|
||||||
|
- Label "User" → "User handle"; `filters.user_id` → `filters.user_handle`; `fetchLog()` passes `user_handle` param
|
||||||
|
- `exportCsv()` replaced with async function calling `api.adminExportAuditLogCsv()`; loading state `exportingCsv` ref; error display
|
||||||
|
- New "Daily exports" section below pagination: loading/empty/populated states, date `<select>` dropdown, Download button with spinner, error display
|
||||||
|
- All reactive state initialized in `<script setup>`; `loadDailyExports()` called in `onMounted()` alongside `fetchLog()`
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Moved get_storage_backend import to module level for testability**
|
||||||
|
- **Found during:** Task 1 - daily exports tests
|
||||||
|
- **Issue:** The plan specified lazy imports inside handler bodies (`from storage import get_storage_backend`). When tests used `patch("api.audit.get_storage_backend", ...)`, the attribute did not exist on the module (the import had not yet executed), causing `AttributeError`.
|
||||||
|
- **Fix:** Moved `from storage import get_storage_backend` and `from storage.minio_backend import MinIOBackend` to module-level imports. This is consistent with how other modules import these — the lazy import pattern was only needed for the cloud backend classes (to avoid circular imports) not for the top-level factory.
|
||||||
|
- **Files modified:** backend/api/audit.py
|
||||||
|
- **Commit:** 839bfe0
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] Updated test_audit_log_export_csv header assertion**
|
||||||
|
- **Found during:** Task 1 - running full test suite after GREEN phase
|
||||||
|
- **Issue:** The existing CSV export test asserted the old header line (without `user_handle,actor_handle`). After enriching the export endpoint per Pitfall 7, the test failed with a header mismatch.
|
||||||
|
- **Fix:** Updated `expected_header` in `test_audit_log_export_csv` to include `user_handle,actor_handle` columns. This is the correct behavior — the test was correct for the old API, and the new assertion is correct for the enriched API.
|
||||||
|
- **Files modified:** backend/tests/test_audit.py
|
||||||
|
- **Commit:** 839bfe0
|
||||||
|
|
||||||
|
## Security Compliance
|
||||||
|
|
||||||
|
All threat model mitigations implemented and verified:
|
||||||
|
- **T-06.2-04-01** (date path traversal): `re.fullmatch(r"\d{4}-\d{2}-\d{2}", date)` gates key construction
|
||||||
|
- **T-06.2-04-02** (unauthenticated access): both new endpoints use `Depends(get_current_admin)`
|
||||||
|
- **T-06.2-04-03** (CSV token bypass via window.location.href): replaced with `fetch()+Blob` pattern carrying Bearer header
|
||||||
|
- **T-06.2-04-05** (event loop blocking): `asyncio.to_thread()` wraps all synchronous MinIO SDK calls
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — all functionality is fully wired.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Files verified present:
|
||||||
|
- backend/api/audit.py — contains `_audit_to_dict_with_handles`, `_build_filtered_query_with_handles`, `/audit-log/daily-exports`, `/audit-log/daily-exports/{date}`, `re.fullmatch`
|
||||||
|
- backend/tests/test_audit.py — all 10 tests pass
|
||||||
|
- frontend/src/api/client.js — contains `adminExportAuditLogCsv`, `adminListDailyExports`, `adminDownloadDailyExport`
|
||||||
|
- frontend/src/components/admin/AuditLogTab.vue — contains "Daily exports", "User handle", no `window.location.href`
|
||||||
|
|
||||||
|
Commits verified:
|
||||||
|
- d7cfc5c — test(06.2-04): add failing tests for handle enrichment, user_handle filter, daily exports
|
||||||
|
- 839bfe0 — feat(06.2-04): backend — handle enrichment, user_handle filter, two daily-export endpoints
|
||||||
|
- 0647e6e — feat(06.2-04): frontend — user_handle filter, fetch+Blob export, daily-export section
|
||||||
+240
-24
@@ -7,34 +7,43 @@ receive 403 Forbidden.
|
|||||||
Implements:
|
Implements:
|
||||||
GET /api/admin/audit-log — paginated, filtered audit log viewer
|
GET /api/admin/audit-log — paginated, filtered audit log viewer
|
||||||
GET /api/admin/audit-log/export — CSV streaming export with same filters
|
GET /api/admin/audit-log/export — CSV streaming export with same filters
|
||||||
|
GET /api/admin/audit-log/daily-exports — list available Celery daily export files
|
||||||
|
GET /api/admin/audit-log/daily-exports/{date} — stream a specific daily export CSV
|
||||||
|
|
||||||
Security invariants:
|
Security invariants:
|
||||||
- Both endpoints use Depends(get_current_admin) — verified by grep
|
- All endpoints use Depends(get_current_admin) — verified by grep
|
||||||
- _audit_to_dict() is a pure whitelist: no filename, extracted_text,
|
- _audit_to_dict() is a pure whitelist: no filename, extracted_text,
|
||||||
password_hash, or credentials_enc can appear in responses (ADMIN-06, D-15)
|
password_hash, or credentials_enc can appear in responses (ADMIN-06, D-15)
|
||||||
- CSV export uses the same _audit_to_dict() helper as the JSON viewer
|
- CSV export uses the same _audit_to_dict_with_handles() helper as the JSON viewer
|
||||||
|
- Date path parameter validated against YYYY-MM-DD regex before MinIO key
|
||||||
|
construction — prevents path traversal (T-06.2-04-01, Pitfall 6)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from db.models import AuditLog, User
|
from db.models import AuditLog, User
|
||||||
from deps.auth import get_current_admin
|
from deps.auth import get_current_admin
|
||||||
from deps.db import get_db
|
from deps.db import get_db
|
||||||
|
from storage import get_storage_backend
|
||||||
|
from storage.minio_backend import MinIOBackend
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["audit"])
|
router = APIRouter(prefix="/api/admin", tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
# ── Safe response helper ──────────────────────────────────────────────────────
|
# ── Safe response helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _audit_to_dict(entry: AuditLog) -> dict:
|
def _audit_to_dict(entry: AuditLog) -> dict:
|
||||||
"""Safe audit log serializer — never includes filename, extracted_text, or
|
"""Safe audit log serializer — never includes filename, extracted_text, or
|
||||||
@@ -55,7 +64,35 @@ def _audit_to_dict(entry: AuditLog) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Query builder helper ──────────────────────────────────────────────────────
|
def _audit_to_dict_with_handles(
|
||||||
|
entry: AuditLog,
|
||||||
|
user_handle: Optional[str],
|
||||||
|
actor_handle: Optional[str],
|
||||||
|
) -> dict:
|
||||||
|
"""Extended audit log serializer that includes user_handle and actor_handle.
|
||||||
|
|
||||||
|
Returns the same fields as _audit_to_dict() plus:
|
||||||
|
- user_handle: str | None (the handle of the user who owns the entry)
|
||||||
|
- actor_handle: str | None (the handle of the actor who performed the event)
|
||||||
|
|
||||||
|
Used by both the JSON viewer and CSV export endpoints (Pitfall 7 — both
|
||||||
|
endpoints must use the enriched function).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": entry.id,
|
||||||
|
"event_type": entry.event_type,
|
||||||
|
"user_id": str(entry.user_id) if entry.user_id else None,
|
||||||
|
"actor_id": str(entry.actor_id) if entry.actor_id else None,
|
||||||
|
"user_handle": user_handle or None,
|
||||||
|
"actor_handle": actor_handle or None,
|
||||||
|
"resource_id": str(entry.resource_id) if entry.resource_id else None,
|
||||||
|
"ip_address": str(entry.ip_address) if entry.ip_address else None,
|
||||||
|
"metadata_": entry.metadata_,
|
||||||
|
"created_at": entry.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Query builder helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_filtered_query(
|
def _build_filtered_query(
|
||||||
start: Optional[datetime],
|
start: Optional[datetime],
|
||||||
@@ -65,8 +102,12 @@ def _build_filtered_query(
|
|||||||
):
|
):
|
||||||
"""Return a SQLAlchemy Select for AuditLog with the given filters applied.
|
"""Return a SQLAlchemy Select for AuditLog with the given filters applied.
|
||||||
|
|
||||||
Shared by both the paginated viewer and the CSV export endpoints to ensure
|
Shared by count queries in both the paginated viewer and the CSV export
|
||||||
consistent filter semantics.
|
endpoints to ensure consistent filter semantics.
|
||||||
|
|
||||||
|
NOTE: This function selects AuditLog only (no JOIN). It is used for COUNT
|
||||||
|
queries to avoid the subquery ambiguity that arises with multi-column JOINs
|
||||||
|
(Pitfall 4). Data queries use _build_filtered_query_with_handles() instead.
|
||||||
"""
|
"""
|
||||||
q = select(AuditLog).order_by(AuditLog.created_at.desc())
|
q = select(AuditLog).order_by(AuditLog.created_at.desc())
|
||||||
if start is not None:
|
if start is not None:
|
||||||
@@ -80,13 +121,129 @@ def _build_filtered_query(
|
|||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def _build_filtered_query_with_handles(
|
||||||
|
start: Optional[datetime],
|
||||||
|
end: Optional[datetime],
|
||||||
|
user_uuid: Optional[uuid.UUID],
|
||||||
|
event_type: Optional[str],
|
||||||
|
):
|
||||||
|
"""Return a multi-column Select that joins User twice for handle enrichment.
|
||||||
|
|
||||||
|
Yields (AuditLog, user_handle: str|None, actor_handle: str|None) tuples.
|
||||||
|
Uses SQLAlchemy aliased() to join User twice without collision:
|
||||||
|
- UserSubject: resolves user_id FK → handle
|
||||||
|
- UserActor: resolves actor_id FK → handle
|
||||||
|
|
||||||
|
outerjoin() ensures entries with NULL user_id or actor_id are still returned.
|
||||||
|
"""
|
||||||
|
UserSubject = aliased(User)
|
||||||
|
UserActor = aliased(User)
|
||||||
|
|
||||||
|
q = (
|
||||||
|
select(
|
||||||
|
AuditLog,
|
||||||
|
UserSubject.handle.label("user_handle"),
|
||||||
|
UserActor.handle.label("actor_handle"),
|
||||||
|
)
|
||||||
|
.outerjoin(UserSubject, UserSubject.id == AuditLog.user_id)
|
||||||
|
.outerjoin(UserActor, UserActor.id == AuditLog.actor_id)
|
||||||
|
.order_by(AuditLog.created_at.desc())
|
||||||
|
)
|
||||||
|
if start is not None:
|
||||||
|
q = q.where(AuditLog.created_at >= start)
|
||||||
|
if end is not None:
|
||||||
|
q = q.where(AuditLog.created_at <= end)
|
||||||
|
if user_uuid is not None:
|
||||||
|
q = q.where(AuditLog.user_id == user_uuid)
|
||||||
|
if event_type is not None:
|
||||||
|
q = q.where(AuditLog.event_type == event_type)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||||
|
# IMPORTANT: daily-export routes are registered BEFORE /audit-log and
|
||||||
|
# /audit-log/export so FastAPI matches the more specific paths first.
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-log/daily-exports")
|
||||||
|
async def list_daily_exports(
|
||||||
|
_admin: User = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""List available Celery daily audit export files from MinIO (D-15).
|
||||||
|
|
||||||
|
Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] }
|
||||||
|
Items are sorted descending by date.
|
||||||
|
|
||||||
|
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
|
||||||
|
Event loop safety: list_objects() is synchronous; wrapped in asyncio.to_thread
|
||||||
|
to avoid blocking the event loop (T-06.2-04-05).
|
||||||
|
"""
|
||||||
|
backend = get_storage_backend()
|
||||||
|
if not isinstance(backend, MinIOBackend):
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
def _list() -> list:
|
||||||
|
objects = backend._client.list_objects(
|
||||||
|
"audit-logs", prefix="audit-logs/", recursive=False
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
for obj in objects:
|
||||||
|
name = obj.object_name or ""
|
||||||
|
if name.endswith(".csv"):
|
||||||
|
date_str = name.removeprefix("audit-logs/").removesuffix(".csv")
|
||||||
|
items.append({"date": date_str, "key": name})
|
||||||
|
items.sort(key=lambda x: x["date"], reverse=True)
|
||||||
|
return items
|
||||||
|
|
||||||
|
items = await asyncio.to_thread(_list)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-log/daily-exports/{date}")
|
||||||
|
async def download_daily_export(
|
||||||
|
date: str,
|
||||||
|
_admin: User = Depends(get_current_admin),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Stream a specific Celery daily audit export file from MinIO (D-16).
|
||||||
|
|
||||||
|
The date path parameter is validated against YYYY-MM-DD regex before
|
||||||
|
MinIO key construction to prevent path traversal (T-06.2-04-01, Pitfall 6).
|
||||||
|
|
||||||
|
Returns: StreamingResponse with Content-Type: text/csv.
|
||||||
|
|
||||||
|
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
|
||||||
|
"""
|
||||||
|
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
|
||||||
|
raise HTTPException(status_code=404, detail="Invalid date format")
|
||||||
|
|
||||||
|
backend = get_storage_backend()
|
||||||
|
key = f"audit-logs/{date}.csv"
|
||||||
|
|
||||||
|
def _get() -> bytes:
|
||||||
|
response = backend._client.get_object("audit-logs", key)
|
||||||
|
try:
|
||||||
|
return response.read()
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
response.release_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
csv_bytes = await asyncio.to_thread(_get)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=404, detail="Export not found")
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([csv_bytes]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="audit-{date}.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/audit-log")
|
@router.get("/audit-log")
|
||||||
async def list_audit_log(
|
async def list_audit_log(
|
||||||
start: Optional[datetime] = Query(default=None),
|
start: Optional[datetime] = Query(default=None),
|
||||||
end: Optional[datetime] = Query(default=None),
|
end: Optional[datetime] = Query(default=None),
|
||||||
user_id: Optional[uuid.UUID] = Query(default=None),
|
user_handle: Optional[str] = Query(default=None),
|
||||||
event_type: Optional[str] = Query(default=None),
|
event_type: Optional[str] = Query(default=None),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
per_page: int = Query(default=50, ge=1, le=500),
|
per_page: int = Query(default=50, ge=1, le=500),
|
||||||
@@ -96,22 +253,51 @@ async def list_audit_log(
|
|||||||
"""Return paginated, filtered audit log entries (ADMIN-06).
|
"""Return paginated, filtered audit log entries (ADMIN-06).
|
||||||
|
|
||||||
Response: { items: [...], total: int, page: int, per_page: int }
|
Response: { items: [...], total: int, page: int, per_page: int }
|
||||||
|
Each item includes user_handle and actor_handle alongside UUID fields (D-11).
|
||||||
Entries never contain filename, extracted_text, or document content (D-15).
|
Entries never contain filename, extracted_text, or document content (D-15).
|
||||||
"""
|
|
||||||
base_q = _build_filtered_query(start, end, user_id, event_type)
|
|
||||||
|
|
||||||
# Total count — same filters, no limit/offset
|
user_handle filter: accepts a plain string handle and resolves to UUID
|
||||||
count_q = select(func.count()).select_from(base_q.subquery())
|
internally. Returns empty results (not 422) for unknown handles (D-12).
|
||||||
|
"""
|
||||||
|
# Handle-to-UUID resolution (D-12, Pattern 4)
|
||||||
|
user_uuid: Optional[uuid.UUID] = None
|
||||||
|
if user_handle:
|
||||||
|
handle_result = await session.execute(
|
||||||
|
select(User.id).where(User.handle == user_handle)
|
||||||
|
)
|
||||||
|
uid = handle_result.scalar_one_or_none()
|
||||||
|
if uid is None:
|
||||||
|
# No user with that handle — return empty results (D-12)
|
||||||
|
return {"items": [], "total": 0, "page": page, "per_page": per_page}
|
||||||
|
user_uuid = uid
|
||||||
|
|
||||||
|
# Count query: use the plain _build_filtered_query (no JOIN) to avoid
|
||||||
|
# COUNT ambiguity on multi-column subqueries (Pitfall 4)
|
||||||
|
count_q = select(func.count(AuditLog.id)).where(True)
|
||||||
|
if start is not None:
|
||||||
|
count_q = count_q.where(AuditLog.created_at >= start)
|
||||||
|
if end is not None:
|
||||||
|
count_q = count_q.where(AuditLog.created_at <= end)
|
||||||
|
if user_uuid is not None:
|
||||||
|
count_q = count_q.where(AuditLog.user_id == user_uuid)
|
||||||
|
if event_type is not None:
|
||||||
|
count_q = count_q.where(AuditLog.event_type == event_type)
|
||||||
count_result = await session.execute(count_q)
|
count_result = await session.execute(count_q)
|
||||||
total = count_result.scalar_one()
|
total = count_result.scalar_one()
|
||||||
|
|
||||||
# Paginated rows
|
# Data query: use enriched JOIN for handle fields
|
||||||
paginated_q = base_q.limit(per_page).offset((page - 1) * per_page)
|
data_q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
|
||||||
result = await session.execute(paginated_q)
|
data_q = data_q.limit(per_page).offset((page - 1) * per_page)
|
||||||
entries = result.scalars().all()
|
result = await session.execute(data_q)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]
|
||||||
|
items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": [_audit_to_dict(e) for e in entries],
|
"items": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": per_page,
|
"per_page": per_page,
|
||||||
@@ -122,7 +308,7 @@ async def list_audit_log(
|
|||||||
async def export_audit_log(
|
async def export_audit_log(
|
||||||
start: Optional[datetime] = Query(default=None),
|
start: Optional[datetime] = Query(default=None),
|
||||||
end: Optional[datetime] = Query(default=None),
|
end: Optional[datetime] = Query(default=None),
|
||||||
user_id: Optional[uuid.UUID] = Query(default=None),
|
user_handle: Optional[str] = Query(default=None),
|
||||||
event_type: Optional[str] = Query(default=None),
|
event_type: Optional[str] = Query(default=None),
|
||||||
format: str = Query(default="csv"), # noqa: A002
|
format: str = Query(default="csv"), # noqa: A002
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
@@ -130,20 +316,49 @@ async def export_audit_log(
|
|||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Stream a CSV export of filtered audit log entries (ADMIN-06).
|
"""Stream a CSV export of filtered audit log entries (ADMIN-06).
|
||||||
|
|
||||||
Uses the same _audit_to_dict() whitelist as the JSON viewer — no filename,
|
Uses the same _audit_to_dict_with_handles() whitelist as the JSON viewer —
|
||||||
extracted_text, or document content appears in the export (D-15, T-04-06-02).
|
includes user_handle and actor_handle; no filename, extracted_text, or
|
||||||
|
document content appears in the export (D-15, T-04-06-02, Pitfall 7).
|
||||||
|
|
||||||
Returns StreamingResponse with Content-Disposition: attachment; filename=audit-export.csv.
|
Returns StreamingResponse with Content-Disposition: attachment; filename=audit-export.csv.
|
||||||
|
|
||||||
|
user_handle filter: same handle-to-UUID resolution as the viewer (D-12).
|
||||||
"""
|
"""
|
||||||
q = _build_filtered_query(start, end, user_id, event_type)
|
# Handle-to-UUID resolution (D-12) — same logic as list_audit_log
|
||||||
|
user_uuid: Optional[uuid.UUID] = None
|
||||||
|
if user_handle:
|
||||||
|
handle_result = await session.execute(
|
||||||
|
select(User.id).where(User.handle == user_handle)
|
||||||
|
)
|
||||||
|
uid = handle_result.scalar_one_or_none()
|
||||||
|
if uid is None:
|
||||||
|
# Unknown handle — return empty CSV
|
||||||
|
empty_output = io.StringIO()
|
||||||
|
fields = [
|
||||||
|
"id", "event_type", "user_id", "actor_id", "user_handle", "actor_handle",
|
||||||
|
"resource_id", "ip_address", "metadata_", "created_at",
|
||||||
|
]
|
||||||
|
writer = csv.DictWriter(empty_output, fieldnames=fields)
|
||||||
|
writer.writeheader()
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([empty_output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
|
||||||
|
)
|
||||||
|
user_uuid = uid
|
||||||
|
|
||||||
|
# Data query with handle enrichment (Pitfall 7 — export must use enriched function)
|
||||||
|
q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
entries = result.scalars().all()
|
rows = result.all()
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"event_type",
|
"event_type",
|
||||||
"user_id",
|
"user_id",
|
||||||
"actor_id",
|
"actor_id",
|
||||||
|
"user_handle",
|
||||||
|
"actor_handle",
|
||||||
"resource_id",
|
"resource_id",
|
||||||
"ip_address",
|
"ip_address",
|
||||||
"metadata_",
|
"metadata_",
|
||||||
@@ -152,8 +367,9 @@ async def export_audit_log(
|
|||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
writer = csv.DictWriter(output, fieldnames=fields)
|
writer = csv.DictWriter(output, fieldnames=fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
for entry in entries:
|
for row in rows:
|
||||||
writer.writerow(_audit_to_dict(entry))
|
entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]
|
||||||
|
writer.writerow(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val))
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
iter([output.getvalue()]),
|
iter([output.getvalue()]),
|
||||||
|
|||||||
+138
-8
@@ -178,8 +178,10 @@ async def test_audit_log_export_csv(async_client, admin_user, db_session):
|
|||||||
f"got '{content_disposition}'"
|
f"got '{content_disposition}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Phase 6.2: CSV now includes user_handle and actor_handle columns (D-11, Pitfall 7)
|
||||||
expected_header = (
|
expected_header = (
|
||||||
"id,event_type,user_id,actor_id,resource_id,ip_address,metadata_,created_at"
|
"id,event_type,user_id,actor_id,user_handle,actor_handle,"
|
||||||
|
"resource_id,ip_address,metadata_,created_at"
|
||||||
)
|
)
|
||||||
assert expected_header in response.text, (
|
assert expected_header in response.text, (
|
||||||
f"CSV header line not found in response. "
|
f"CSV header line not found in response. "
|
||||||
@@ -195,30 +197,158 @@ async def test_audit_log_export_csv(async_client, admin_user, db_session):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Phase 6.2 Wave 0 xfail stubs — ADMIN-06 audit enrichment + daily exports
|
# Phase 6.2 — ADMIN-06 audit enrichment + daily exports (promoted stubs)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def test_audit_log_includes_user_handle(async_client, admin_user, db_session):
|
async def test_audit_log_includes_user_handle(async_client, admin_user, db_session):
|
||||||
"""Audit log items include user_handle and actor_handle strings (D-11)"""
|
"""Audit log items include user_handle and actor_handle strings (D-11)"""
|
||||||
pytest.xfail("Phase 6.2 — not implemented yet")
|
await _seed_audit(db_session, admin_user["user"].id)
|
||||||
|
|
||||||
|
response = await async_client.get(
|
||||||
|
"/api/admin/audit-log",
|
||||||
|
headers=admin_user["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
items = body["items"]
|
||||||
|
assert len(items) >= 1, "expected at least one seeded audit entry"
|
||||||
|
|
||||||
|
first = items[0]
|
||||||
|
assert "user_handle" in first, "missing key 'user_handle' in audit item"
|
||||||
|
assert "actor_handle" in first, "missing key 'actor_handle' in audit item"
|
||||||
|
# The seeded entry was created for admin_user — handle must match
|
||||||
|
assert first["user_handle"] == admin_user["user"].handle, (
|
||||||
|
f"expected user_handle={admin_user['user'].handle!r}, got {first['user_handle']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_audit_log_filter_by_handle(async_client, admin_user, db_session):
|
async def test_audit_log_filter_by_handle(async_client, admin_user, db_session, second_auth_user):
|
||||||
"""GET /api/admin/audit-log?user_handle=X filters to matching entries (D-12)"""
|
"""GET /api/admin/audit-log?user_handle=X filters to matching entries (D-12)"""
|
||||||
pytest.xfail("Phase 6.2 — not implemented yet")
|
# Seed one entry for admin_user and one for second_auth_user
|
||||||
|
await _seed_audit(db_session, admin_user["user"].id)
|
||||||
|
await _seed_audit(db_session, second_auth_user["user"].id)
|
||||||
|
|
||||||
|
response = await async_client.get(
|
||||||
|
"/api/admin/audit-log",
|
||||||
|
params={"user_handle": admin_user["user"].handle},
|
||||||
|
headers=admin_user["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
items = body["items"]
|
||||||
|
assert len(items) >= 1, "expected at least one filtered entry for admin_user"
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
assert item["user_handle"] == admin_user["user"].handle, (
|
||||||
|
f"filter returned entry for wrong user: {item['user_handle']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second user's entry must not appear
|
||||||
|
second_handle = second_auth_user["user"].handle
|
||||||
|
assert not any(item["user_handle"] == second_handle for item in items), (
|
||||||
|
f"second user's entry appeared in filtered results"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_audit_log_filter_unknown_handle(async_client, admin_user, db_session):
|
async def test_audit_log_filter_unknown_handle(async_client, admin_user, db_session):
|
||||||
"""GET /api/admin/audit-log?user_handle=unknown returns empty items list, not 422 (D-12)"""
|
"""GET /api/admin/audit-log?user_handle=unknown returns empty items list, not 422 (D-12)"""
|
||||||
pytest.xfail("Phase 6.2 — not implemented yet")
|
response = await async_client.get(
|
||||||
|
"/api/admin/audit-log",
|
||||||
|
params={"user_handle": "definitely_does_not_exist"},
|
||||||
|
headers=admin_user["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"expected 200 for unknown handle, got {response.status_code}: {response.text[:200]}"
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
assert body["items"] == [], f"expected empty items list, got {body['items']}"
|
||||||
|
assert body["total"] == 0, f"expected total=0, got {body['total']}"
|
||||||
|
|
||||||
|
|
||||||
async def test_daily_exports_list(async_client, admin_user):
|
async def test_daily_exports_list(async_client, admin_user):
|
||||||
"""GET /api/admin/audit-log/daily-exports returns {items: [...]} (D-15)"""
|
"""GET /api/admin/audit-log/daily-exports returns {items: [...]} (D-15)"""
|
||||||
pytest.xfail("Phase 6.2 — not implemented yet")
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# Create fake MinIO objects
|
||||||
|
fake_obj1 = MagicMock()
|
||||||
|
fake_obj1.object_name = "audit-logs/2026-05-30.csv"
|
||||||
|
fake_obj1.is_dir = False
|
||||||
|
|
||||||
|
fake_obj2 = MagicMock()
|
||||||
|
fake_obj2.object_name = "audit-logs/2026-05-29.csv"
|
||||||
|
fake_obj2.is_dir = False
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.list_objects.return_value = iter([fake_obj1, fake_obj2])
|
||||||
|
|
||||||
|
mock_backend = MagicMock()
|
||||||
|
mock_backend._client = mock_client
|
||||||
|
|
||||||
|
from storage.minio_backend import MinIOBackend
|
||||||
|
|
||||||
|
with patch("api.audit.get_storage_backend", return_value=mock_backend), \
|
||||||
|
patch("api.audit.MinIOBackend", MinIOBackend):
|
||||||
|
response = await async_client.get(
|
||||||
|
"/api/admin/audit-log/daily-exports",
|
||||||
|
headers=admin_user["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert "items" in body, f"expected 'items' key in response, got: {body}"
|
||||||
|
items = body["items"]
|
||||||
|
assert isinstance(items, list)
|
||||||
|
# Items must be sorted descending by date
|
||||||
|
if len(items) >= 2:
|
||||||
|
dates = [item["date"] for item in items]
|
||||||
|
assert dates == sorted(dates, reverse=True), (
|
||||||
|
f"expected dates sorted descending, got {dates}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_daily_export_download(async_client, admin_user):
|
async def test_daily_export_download(async_client, admin_user):
|
||||||
"""GET /api/admin/audit-log/daily-exports/{date} returns CSV bytes with Content-Disposition (D-16)"""
|
"""GET /api/admin/audit-log/daily-exports/{date} returns CSV bytes with Content-Disposition (D-16)"""
|
||||||
pytest.xfail("Phase 6.2 — not implemented yet")
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
fake_csv = b"id,event_type,user_id\n1,document.uploaded,abc\n"
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = fake_csv
|
||||||
|
mock_response.close.return_value = None
|
||||||
|
mock_response.release_conn.return_value = None
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.get_object.return_value = mock_response
|
||||||
|
|
||||||
|
mock_backend = MagicMock()
|
||||||
|
mock_backend._client = mock_client
|
||||||
|
|
||||||
|
with patch("api.audit.get_storage_backend", return_value=mock_backend):
|
||||||
|
response = await async_client.get(
|
||||||
|
"/api/admin/audit-log/daily-exports/2026-05-30",
|
||||||
|
headers=admin_user["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
assert "text/csv" in content_type, (
|
||||||
|
f"expected content-type text/csv, got {content_type!r}"
|
||||||
|
)
|
||||||
|
content_disposition = response.headers.get("content-disposition", "")
|
||||||
|
assert "2026-05-30" in content_disposition, (
|
||||||
|
f"expected '2026-05-30' in Content-Disposition, got {content_disposition!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalid date must return 404
|
||||||
|
with patch("api.audit.get_storage_backend", return_value=mock_backend):
|
||||||
|
bad_response = await async_client.get(
|
||||||
|
"/api/admin/audit-log/daily-exports/invalid-date",
|
||||||
|
headers=admin_user["headers"],
|
||||||
|
)
|
||||||
|
assert bad_response.status_code == 404, (
|
||||||
|
f"expected 404 for invalid date, got {bad_response.status_code}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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