From 09550cfca00dcce02cb53c1ab479c09261b978ff Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 29 Apr 2026 11:34:06 +0100 Subject: [PATCH] feat: audit log integration sweep + cost tracker URL fix + audit log admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix cost tracker dashboard URL (cost.oliver.agency → optical-dev.oliver.solutions/cost-tracker/analytics) in UserList, QCDetail, FinalDetail; centralise into src/lib/costTracker.ts - Wire audit logging across backend routes (was 1 call site, now covers all key events): · routes_auth: LOGIN_SUCCESS/FAILURE for local + MS SSO, LOGOUT · routes_files: FILE_UPLOAD on signed URL generation · routes_jobs: JOB_CREATE, JOB_APPROVE, JOB_REJECT, JOB_STATUS_CHANGE, JOB_DELETE, VTT_EDIT · routes_admin: USER_CREATE, USER_UPDATE, USER_ROLE_CHANGE, USER_DEACTIVATE - Add Audit Log admin UI page (/admin/audit-log): · Three tabs: All Events (paginated, server-side filters), Security Events, User Activity · Filters: action group, severity, success/failure, free-text search · Click-to-expand row shows IP, request ID, resource, details JSON · Wired into App.tsx (RoleGate: production + admin) and sidebar nav Co-Authored-By: Claude Opus 4.7 --- backend/app/api/v1/routes_admin.py | 17 +- backend/app/api/v1/routes_auth.py | 23 +- backend/app/api/v1/routes_files.py | 18 +- backend/app/api/v1/routes_jobs.py | 55 +++- frontend/src/App.tsx | 8 + frontend/src/components/Layout/Sidebar.tsx | 6 + frontend/src/lib/api.ts | 32 ++ frontend/src/lib/costTracker.ts | 2 + frontend/src/routes/admin/AuditLog.tsx | 347 +++++++++++++++++++++ frontend/src/routes/admin/FinalDetail.tsx | 3 +- frontend/src/routes/admin/QCDetail.tsx | 3 +- frontend/src/routes/admin/UserList.tsx | 3 +- frontend/src/types/api.ts | 50 +++ 13 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 frontend/src/lib/costTracker.ts create mode 100644 frontend/src/routes/admin/AuditLog.tsx diff --git a/backend/app/api/v1/routes_admin.py b/backend/app/api/v1/routes_admin.py index 54949ce..4a29cfb 100644 --- a/backend/app/api/v1/routes_admin.py +++ b/backend/app/api/v1/routes_admin.py @@ -10,7 +10,7 @@ from ...core.dependencies import get_current_user, require_roles from ...core.logging import get_logger from ...core.security import get_password_hash, verify_password from ...models.user import User, UserRole -from ...models.audit_log import AuditLogQuery, AuditLogResponse +from ...models.audit_log import AuditAction, AuditLogQuery, AuditLogResponse from ...schemas.auth import ( AdminStatsResponse, ChangePasswordRequest, @@ -103,6 +103,7 @@ async def get_user( @router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def create_user( user_data: CreateUserRequest, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -135,6 +136,10 @@ async def create_user( app_metrics.record_auth_attempt("user_created", user_data.role.value) logger.info(f"Admin {current_user.id} created user {user_id} with role {user_data.role.value}") + await log_user_management( + AuditAction.USER_CREATE, user_id, current_user, request, + details={"email": user_data.email, "role": user_data.role.value}, + ) return UserResponse( id=user_id, @@ -152,6 +157,7 @@ async def create_user( async def update_user( user_id: str, user_update: UpdateUserRequest, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -193,6 +199,11 @@ async def update_user( ) logger.info(f"Admin {current_user.id} updated user {user_id}") + action = AuditAction.USER_ROLE_CHANGE if user_update.role else AuditAction.USER_UPDATE + await log_user_management( + action, user_id, current_user, request, + details={k: v for k, v in user_update.dict(exclude_none=True).items()}, + ) return UserResponse( id=str(result["_id"]), @@ -209,6 +220,7 @@ async def update_user( @router.delete("/users/{user_id}") async def deactivate_user( user_id: str, + request: Request, current_user: User = Depends(require_roles(UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -236,7 +248,8 @@ async def deactivate_user( ) logger.info(f"Admin {current_user.id} deactivated user {user_id}") - + await log_user_management(AuditAction.USER_DEACTIVATE, user_id, current_user, request) + return {"message": "User deactivated successfully"} diff --git a/backend/app/api/v1/routes_auth.py b/backend/app/api/v1/routes_auth.py index 4f51f34..e038176 100644 --- a/backend/app/api/v1/routes_auth.py +++ b/backend/app/api/v1/routes_auth.py @@ -26,6 +26,8 @@ from ...services.microsoft_auth import ( MicrosoftTokenValidationError, MicrosoftAuthError, ) +from ...services.audit_logger import log_auth_success, log_auth_failure, audit_logger +from ...models.audit_log import AuditAction, AuditLogSeverity router = APIRouter(prefix="/auth", tags=["auth"]) security = HTTPBearer() @@ -34,6 +36,7 @@ security = HTTPBearer() @router.post("/login", response_model=LoginResponse) async def login( login_data: LoginRequest, + request: Request, response: Response, ): print(f"LOGIN: Starting login for {login_data.email}") @@ -48,6 +51,7 @@ async def login( user_doc = await db.users.find_one({"email": login_data.email}) print(f"LOGIN: User lookup complete, found: {user_doc is not None}") if not user_doc: + await log_auth_failure(login_data.email, request, "User not found") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", @@ -57,6 +61,7 @@ async def login( # Check if user uses Microsoft authentication if user.auth_provider == AuthProvider.MICROSOFT: + await log_auth_failure(login_data.email, request, "Account uses Microsoft SSO") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="This account uses Microsoft authentication. Please sign in with Microsoft.", @@ -64,12 +69,14 @@ async def login( # Verify password if not user.hashed_password or not verify_password(login_data.password, user.hashed_password): + await log_auth_failure(login_data.email, request, "Invalid password") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", ) if not user.is_active: + await log_auth_failure(login_data.email, request, "Account disabled") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User account is disabled", @@ -90,12 +97,13 @@ async def login( max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60, ) + await log_auth_success(user, request) return LoginResponse( access_token=access_token, user_id=str(user.id), role=user.role, ) - + finally: # Close database connection client.close() @@ -104,6 +112,7 @@ async def login( @router.post("/microsoft", response_model=MicrosoftLoginResponse) async def microsoft_login( login_data: MicrosoftLoginRequest, + request: Request, response: Response, ): """Authenticate user with Microsoft ID token. @@ -125,12 +134,14 @@ async def microsoft_login( print(f"MICROSOFT LOGIN: Token validated for {user_info.email}") except MicrosoftTokenValidationError as e: print(f"MICROSOFT LOGIN ERROR: Token validation failed: {e}") + await log_auth_failure(login_data.id_token[:20] + "…", request, f"MS token invalid: {e}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Microsoft authentication failed: {str(e)}", ) except MicrosoftAuthError as e: print(f"MICROSOFT LOGIN ERROR: Authentication error: {e}") + await log_auth_failure("microsoft-sso", request, f"MS auth service error: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Microsoft authentication service error", @@ -189,6 +200,7 @@ async def microsoft_login( # Check if user is active if not user.is_active: + await log_auth_failure(user.email, request, "Account disabled") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User account is disabled", @@ -210,6 +222,7 @@ async def microsoft_login( ) print(f"MICROSOFT LOGIN: Authentication successful for {user.email}") + await log_auth_success(user, request) return MicrosoftLoginResponse( access_token=access_token, user_id=str(user.id), @@ -310,7 +323,7 @@ async def refresh_token( @router.post("/logout", response_model=LogoutResponse) -async def logout(response: Response): +async def logout(request: Request, response: Response): # Clear refresh token cookie response.delete_cookie( key="refresh_token", @@ -320,4 +333,10 @@ async def logout(response: Response): domain=settings.cookie_domain if settings.app_env == "prod" else None, ) + await audit_logger.log_action( + action=AuditAction.LOGOUT, + description="User logged out", + request=request, + severity=AuditLogSeverity.INFO, + ) return LogoutResponse() diff --git a/backend/app/api/v1/routes_files.py b/backend/app/api/v1/routes_files.py index 5021655..cddde27 100644 --- a/backend/app/api/v1/routes_files.py +++ b/backend/app/api/v1/routes_files.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from motor.motor_asyncio import AsyncIOMotorDatabase from ...core.database import get_database @@ -6,6 +6,8 @@ from ...core.dependencies import get_current_user from ...models.user import User from ...schemas.file import SignedUploadRequest, SignedUploadResponse from ...services.gcs import generate_signed_upload_url +from ...services.audit_logger import audit_logger +from ...models.audit_log import AuditAction router = APIRouter(prefix="/files", tags=["files"]) @@ -13,6 +15,7 @@ router = APIRouter(prefix="/files", tags=["files"]) @router.post("/signed-upload", response_model=SignedUploadResponse) async def get_signed_upload_url( request: SignedUploadRequest, + http_request: Request, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -38,12 +41,23 @@ async def get_signed_upload_url( max_size=request.max_size or 1024 * 1024 * 1024 # 1GB default ) + await audit_logger.log_action( + action=AuditAction.FILE_UPLOAD, + description=f"Signed upload URL generated for {request.filename}", + user=current_user, + request=http_request, + resource_type="file", + resource_name=request.filename, + details={"blob_path": blob_path, "content_type": request.content_type}, + ) return SignedUploadResponse( upload_url=signed_data["url"], fields=signed_data["fields"], blob_path=blob_path ) - + + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 19a920d..efe2f4d 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Optional from bson import ObjectId -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status from fastapi.responses import StreamingResponse from motor.motor_asyncio import AsyncIOMotorDatabase @@ -59,6 +59,8 @@ from ...services.validation import asset_validation_service from ...tasks import celery_app from ...tasks.ingest_and_ai import ingest_and_ai_task from ...tasks.translate_and_synthesize import translate_and_synthesize_task +from ...services.audit_logger import audit_logger, log_job_action +from ...models.audit_log import AuditAction logger = get_logger(__name__) router = APIRouter(prefix="/jobs", tags=["jobs"]) @@ -71,6 +73,7 @@ async def create_job( file: UploadFile = File(...), brand_context: Optional[str] = Form(None), project_id: Optional[str] = Form(None), + request: Request = None, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -172,6 +175,10 @@ async def create_job( detail=f"Failed to start processing: {e}" ) + await log_job_action( + AuditAction.JOB_CREATE, job_id, current_user, request, + details={"title": title, "filename": file.filename}, + ) return JobResponse( id=job_id, title=title, @@ -694,6 +701,7 @@ async def update_job( async def approve_source( job_id: str, request: ApproveSourceRequest, + http_request: Request = None, current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -759,6 +767,10 @@ async def approve_source( # No need to trigger translate_and_synthesize_task - all processing done before QC logger.info(f"Job {job_id} approved, transitioning directly to final review") + await log_job_action( + AuditAction.JOB_APPROVE, job_id, current_user, http_request, + details={"notes": request.notes, "new_status": new_status.value}, + ) return JobResponse( id=str(result["_id"]), title=result["title"], @@ -792,6 +804,7 @@ async def approve_english( async def reject_job( job_id: str, request: RejectJobRequest, + http_request: Request = None, current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -822,6 +835,10 @@ async def reject_job( detail="Job not found or not in pending QC status" ) + await log_job_action( + AuditAction.JOB_REJECT, job_id, current_user, http_request, + details={"notes": request.notes}, + ) return JobResponse( id=str(result["_id"]), title=result["title"], @@ -839,6 +856,7 @@ async def reject_job( async def complete_job( job_id: str, request: CompleteJobRequest, + http_request: Request = None, current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -891,6 +909,11 @@ async def complete_job( ) + await log_job_action( + AuditAction.JOB_APPROVE, job_id, current_user, http_request, + details={"notes": request.notes, "new_status": JobStatus.COMPLETED.value}, + ) + # Trigger client notification task now that job is completed try: from ...tasks.notify import notify_client_task @@ -916,6 +939,7 @@ async def complete_job( async def reject_final_review( job_id: str, request: RejectJobRequest, + http_request: Request = None, current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -946,7 +970,10 @@ async def reject_final_review( detail="Job not found or not in pending final review status" ) - + await log_job_action( + AuditAction.JOB_REJECT, job_id, current_user, http_request, + details={"notes": request.notes, "stage": "final_review"}, + ) return JobResponse( id=str(result["_id"]), title=result["title"], @@ -977,6 +1004,7 @@ RETURN_TO_QC_ELIGIBLE_STATUSES = [ async def return_to_qc( job_id: str, request: ReturnToQCRequest, + http_request: Request = None, current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -1022,6 +1050,10 @@ async def return_to_qc( logger.info(f"Job {job_id} returned to QC by {current_user.email}: {request.notes}") + await log_job_action( + AuditAction.JOB_STATUS_CHANGE, job_id, current_user, http_request, + details={"new_status": JobStatus.PENDING_QC.value, "notes": request.notes}, + ) return JobResponse( id=str(result["_id"]), title=result["title"], @@ -1227,6 +1259,7 @@ async def get_job_vtt_content( async def update_job_vtt_content( job_id: str, request: VttUpdateRequest, + http_request: Request = None, current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -1371,6 +1404,19 @@ async def update_job_vtt_content( return_document=True ) + await audit_logger.log_action( + action=AuditAction.VTT_EDIT, + description=f"VTT content updated for job {job_id} lang={target_language}", + user=current_user, + request=http_request, + resource_type="job", + resource_id=job_id, + details={ + "lang": target_language, + "captions_updated": request.captions_vtt is not None, + "ad_updated": request.audio_description_vtt is not None, + }, + ) return JobResponse( id=str(result["_id"]), title=result["title"], @@ -1523,6 +1569,7 @@ async def adjust_vtt_timing( @router.delete("/{job_id}", response_model=JobDeleteResponse) async def delete_job( job_id: str, + http_request: Request = None, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), ): @@ -1567,6 +1614,10 @@ async def delete_job( ) logger.info(f"Successfully deleted job {job_id}") + await log_job_action( + AuditAction.JOB_DELETE, job_id, current_user, http_request, + details={"title": job_doc.get("title")}, + ) return {"message": f"Job {job_id} deleted successfully"} except Exception as e: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 87acd08..02564cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { UserList } from './routes/admin/UserList'; import { UserDetail } from './routes/admin/UserDetail'; import { ClientList } from './routes/admin/ClientList'; import { ClientDetail } from './routes/admin/ClientDetail'; +import { AuditLog } from './routes/admin/AuditLog'; import { Downloads } from './routes/Downloads'; import { AcceptInvite } from './routes/AcceptInvite'; import { NoAccess } from './routes/NoAccess'; @@ -147,6 +148,13 @@ function AppContent() { } /> + + + + + + } /> diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index ba572c5..21f9f62 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -66,6 +66,12 @@ export function Sidebar({ onMobileClose }: SidebarProps) { icon: '🏢', roles: ['admin', 'project_manager'], }, + { + label: 'Audit Log', + href: '/admin/audit-log', + icon: '📋', + roles: ['production', 'admin'], + }, ]; const filteredItems = sidebarItems.filter(item => diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 900ca26..1958b52 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -663,6 +663,38 @@ class ApiClient { const r = await this.client.post('/invitations/accept', data); return r.data; } + + // ── Audit Logs ──────────────────────────────────────────────────────────── + + async getAuditLogs(query: import('../types/api').AuditLogQuery = {}): Promise { + const params = new URLSearchParams(); + if (query.action) params.append('action', query.action); + if (query.severity) params.append('severity', query.severity); + if (query.user_id) params.append('user_id', query.user_id); + if (query.user_email) params.append('user_email', query.user_email); + if (query.resource_type) params.append('resource_type', query.resource_type); + if (query.resource_id) params.append('resource_id', query.resource_id); + if (query.success !== undefined) params.append('success', String(query.success)); + if (query.search) params.append('search', query.search); + if (query.start_date) params.append('start_date', query.start_date); + if (query.end_date) params.append('end_date', query.end_date); + if (query.skip !== undefined) params.append('skip', String(query.skip)); + if (query.limit !== undefined) params.append('limit', String(query.limit)); + if (query.sort_by) params.append('sort_by', query.sort_by); + if (query.sort_order !== undefined) params.append('sort_order', String(query.sort_order)); + const r = await this.client.get(`/admin/audit-logs?${params.toString()}`); + return r.data; + } + + async getUserAuditLogs(userId: string, days = 30): Promise { + const r = await this.client.get(`/admin/audit-logs/user/${userId}?days=${days}`); + return r.data; + } + + async getSecurityEvents(hours = 24): Promise { + const r = await this.client.get(`/admin/audit-logs/security?hours=${hours}`); + return r.data; + } } export const apiClient = new ApiClient(); diff --git a/frontend/src/lib/costTracker.ts b/frontend/src/lib/costTracker.ts new file mode 100644 index 0000000..cf1c11c --- /dev/null +++ b/frontend/src/lib/costTracker.ts @@ -0,0 +1,2 @@ +export const COST_TRACKER_DASHBOARD_URL = + 'https://optical-dev.oliver.solutions/cost-tracker/analytics'; diff --git a/frontend/src/routes/admin/AuditLog.tsx b/frontend/src/routes/admin/AuditLog.tsx new file mode 100644 index 0000000..ecbc665 --- /dev/null +++ b/frontend/src/routes/admin/AuditLog.tsx @@ -0,0 +1,347 @@ +import { useState, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../lib/api'; +import type { AuditLogEntry, AuditLogQuery, AuditSeverity } from '../../types/api'; + +const PAGE_SIZE = 50; + +const SEVERITY_COLORS: Record = { + info: 'bg-blue-100 text-blue-800', + warning: 'bg-yellow-100 text-yellow-800', + error: 'bg-red-100 text-red-800', + critical: 'bg-red-200 text-red-900 font-semibold', +}; + +const ACTION_GROUPS: Record = { + Auth: ['login_success', 'login_failure', 'logout', 'token_refresh', 'password_change', 'password_reset'], + Jobs: ['job_create', 'job_update', 'job_delete', 'job_approve', 'job_reject', 'job_cancel', 'job_status_change'], + VTT: ['vtt_edit', 'vtt_approve', 'vtt_reject'], + Files: ['file_upload', 'file_download', 'file_delete', 'file_access'], + Users: ['user_create', 'user_update', 'user_delete', 'user_role_change', 'user_activate', 'user_deactivate'], + Security: ['rate_limit_exceeded', 'validation_failure', 'unauthorized_access', 'suspicious_activity'], + Admin: ['admin_config_change', 'admin_system_action', 'admin_data_export', 'admin_audit_access'], +}; + +type Tab = 'all' | 'security' | 'user'; + +function formatDate(ts: string) { + return new Date(ts).toLocaleString('en-GB', { + day: '2-digit', month: '2-digit', year: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); +} + +function EntryDetail({ entry }: { entry: AuditLogEntry }) { + return ( +
+
+ {entry.ip_address &&
IP: {entry.ip_address}
} + {entry.request_id &&
Request ID: {entry.request_id}
} + {entry.resource_type &&
Resource: {entry.resource_type} {entry.resource_id && ({entry.resource_id})}
} + {entry.user_agent &&
User Agent: {entry.user_agent}
} +
+ {entry.error_message && ( +
Error: {entry.error_message}
+ )} + {entry.details && Object.keys(entry.details).length > 0 && ( +
+          {JSON.stringify(entry.details, null, 2)}
+        
+ )} +
+ ); +} + +function AuditTable({ logs, isLoading }: { logs: AuditLogEntry[]; isLoading: boolean }) { + const [expanded, setExpanded] = useState(null); + + if (isLoading) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (logs.length === 0) { + return

No audit log entries found.

; + } + + return ( +
+ + + + + + + + + + + + + {logs.map((entry, idx) => { + const key = entry.id ?? `${idx}`; + const isOpen = expanded === key; + return [ + setExpanded(isOpen ? null : key)} + > + + + + + + + , + isOpen && ( + + + + ), + ]; + })} + +
TimeActionSevActorDescriptionStatus
+ {formatDate(entry.timestamp)} + {entry.action} + + {entry.severity} + + + {entry.user_email ? ( + {entry.user_email} + ) : } + {entry.description} + {entry.success + ? OK + : FAIL} +
+ +
+
+ ); +} + +export function AuditLog() { + const [tab, setTab] = useState('all'); + const [page, setPage] = useState(0); + const [filters, setFilters] = useState({ limit: PAGE_SIZE }); + const [search, setSearch] = useState(''); + const [actionFilter, setActionFilter] = useState(''); + const [severityFilter, setSeverityFilter] = useState(''); + const [successFilter, setSuccessFilter] = useState(''); + const [securityHours, setSecurityHours] = useState(24); + const [userIdInput, setUserIdInput] = useState(''); + const [activeUserId, setActiveUserId] = useState(''); + + const buildQuery = useCallback((): AuditLogQuery => ({ + ...filters, + skip: page * PAGE_SIZE, + limit: PAGE_SIZE, + search: search || undefined, + action: actionFilter || undefined, + severity: (severityFilter as AuditSeverity) || undefined, + success: successFilter === '' ? undefined : successFilter === 'true', + }), [filters, page, search, actionFilter, severityFilter, successFilter]); + + const allQuery = useQuery({ + queryKey: ['audit-logs', buildQuery()], + queryFn: () => api.getAuditLogs(buildQuery()), + enabled: tab === 'all', + refetchInterval: 30_000, + }); + + const securityQuery = useQuery({ + queryKey: ['audit-security', securityHours], + queryFn: () => api.getSecurityEvents(securityHours), + enabled: tab === 'security', + refetchInterval: 30_000, + }); + + const userQuery = useQuery({ + queryKey: ['audit-user', activeUserId], + queryFn: () => api.getUserAuditLogs(activeUserId), + enabled: tab === 'user' && !!activeUserId, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPage(0); + }; + + const totalPages = tab === 'all' && allQuery.data + ? Math.ceil(allQuery.data.total_count / PAGE_SIZE) + : 1; + + return ( +
+
+

Audit Log

+ Auto-refreshes every 30s +
+ + {/* Tabs */} +
+ {(['all', 'security', 'user'] as Tab[]).map(t => ( + + ))} +
+ + {/* All Events tab filters */} + {tab === 'all' && ( +
+ setSearch(e.target.value)} + className="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none w-48" + /> + + + + + {(search || actionFilter || severityFilter || successFilter) && ( + + )} +
+ )} + + {/* Security tab controls */} + {tab === 'security' && ( +
+ + +
+ )} + + {/* User activity tab controls */} + {tab === 'user' && ( +
{ e.preventDefault(); setActiveUserId(userIdInput.trim()); }} + > + setUserIdInput(e.target.value)} + className="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none w-72" + /> + +
+ )} + + {/* Results */} + {tab === 'all' && ( + <> + {allQuery.data && ( +

+ {allQuery.data.total_count.toLocaleString()} events total +

+ )} + + {totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + +
+ )} + + )} + + {tab === 'security' && ( + + )} + + {tab === 'user' && ( + activeUserId + ? + :

Enter a user ID above and click Load.

+ )} +
+ ); +} diff --git a/frontend/src/routes/admin/FinalDetail.tsx b/frontend/src/routes/admin/FinalDetail.tsx index c4002e2..02c3479 100644 --- a/frontend/src/routes/admin/FinalDetail.tsx +++ b/frontend/src/routes/admin/FinalDetail.tsx @@ -1,4 +1,5 @@ import { useParams, useNavigate } from 'react-router-dom'; +import { COST_TRACKER_DASHBOARD_URL } from '../../lib/costTracker'; import { useState, useEffect } from 'react'; import { useJob, useCompleteJob, useJobVttContent, useRejectFinalReview, useJobValidation, useJobDownloads, useUpdateJob } from '../../hooks/useJob'; import { StatusBadge } from '../../components/StatusBadge'; @@ -318,7 +319,7 @@ export function FinalDetail() {

Links this job to a project in the{' '} - AI Cost Dashboard. + AI Cost Dashboard.

diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index f172ba8..6c49015 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useMemo } from 'react'; +import { COST_TRACKER_DASHBOARD_URL } from '../../lib/costTracker'; import { useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import { useJob, useApproveEnglish, useRejectJob, useJobVttContent, useUpdateJobVtt, useJobDownloads, useAdjustVttTiming, useUpdateTTSPreferences, useUpdateJob } from '../../hooks/useJob'; @@ -1111,7 +1112,7 @@ export function QCDetail() {

Links this job to a project in the{' '} - AI Cost Dashboard. + AI Cost Dashboard.

diff --git a/frontend/src/routes/admin/UserList.tsx b/frontend/src/routes/admin/UserList.tsx index 949d1a6..0ab8a3c 100644 --- a/frontend/src/routes/admin/UserList.tsx +++ b/frontend/src/routes/admin/UserList.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { COST_TRACKER_DASHBOARD_URL } from '../../lib/costTracker'; import { Link } from 'react-router-dom'; import { useUsers, @@ -94,7 +95,7 @@ export function UserList() {

User Management