feat: audit log integration sweep + cost tracker URL fix + audit log admin UI

- 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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-29 11:34:06 +01:00
parent 0f15d192cb
commit 09550cfca0
13 changed files with 556 additions and 11 deletions

View file

@ -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"}

View file

@ -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()

View file

@ -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,

View file

@ -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:

View file

@ -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() {
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/admin/audit-log" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['production', 'admin']}>
<AuditLog />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/downloads/:id" element={
<AuthenticatedRoute>
<Downloads />

View file

@ -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 =>

View file

@ -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<import('../types/api').AuditLogListResponse> {
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<import('../types/api').AuditLogEntry[]> {
const r = await this.client.get(`/admin/audit-logs/user/${userId}?days=${days}`);
return r.data;
}
async getSecurityEvents(hours = 24): Promise<import('../types/api').AuditLogEntry[]> {
const r = await this.client.get(`/admin/audit-logs/security?hours=${hours}`);
return r.data;
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,2 @@
export const COST_TRACKER_DASHBOARD_URL =
'https://optical-dev.oliver.solutions/cost-tracker/analytics';

View file

@ -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<AuditSeverity, string> = {
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<string, string[]> = {
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 (
<div className="p-4 bg-gray-50 border-t text-sm space-y-2">
<div className="grid grid-cols-2 gap-x-8 gap-y-1">
{entry.ip_address && <div><span className="text-gray-500">IP:</span> {entry.ip_address}</div>}
{entry.request_id && <div><span className="text-gray-500">Request ID:</span> <code className="text-xs">{entry.request_id}</code></div>}
{entry.resource_type && <div><span className="text-gray-500">Resource:</span> {entry.resource_type} {entry.resource_id && <code className="text-xs">({entry.resource_id})</code>}</div>}
{entry.user_agent && <div className="col-span-2"><span className="text-gray-500">User Agent:</span> <span className="text-xs">{entry.user_agent}</span></div>}
</div>
{entry.error_message && (
<div className="text-red-600"><span className="text-gray-500">Error:</span> {entry.error_message}</div>
)}
{entry.details && Object.keys(entry.details).length > 0 && (
<pre className="bg-white border rounded p-2 text-xs overflow-auto max-h-40">
{JSON.stringify(entry.details, null, 2)}
</pre>
)}
</div>
);
}
function AuditTable({ logs, isLoading }: { logs: AuditLogEntry[]; isLoading: boolean }) {
const [expanded, setExpanded] = useState<string | null>(null);
if (isLoading) {
return (
<div className="animate-pulse space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-200 rounded" />
))}
</div>
);
}
if (logs.length === 0) {
return <p className="text-gray-500 text-center py-8">No audit log entries found.</p>;
}
return (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-600">Time</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Action</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Sev</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Actor</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Description</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{logs.map((entry, idx) => {
const key = entry.id ?? `${idx}`;
const isOpen = expanded === key;
return [
<tr
key={key}
className="hover:bg-gray-50 cursor-pointer"
onClick={() => setExpanded(isOpen ? null : key)}
>
<td className="px-3 py-2 whitespace-nowrap text-gray-500 font-mono text-xs">
{formatDate(entry.timestamp)}
</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{entry.action}</td>
<td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded text-xs ${SEVERITY_COLORS[entry.severity]}`}>
{entry.severity}
</span>
</td>
<td className="px-3 py-2 text-xs">
{entry.user_email ? (
<span title={`${entry.user_role ?? ''}`}>{entry.user_email}</span>
) : <span className="text-gray-400"></span>}
</td>
<td className="px-3 py-2 text-gray-700 max-w-xs truncate">{entry.description}</td>
<td className="px-3 py-2">
{entry.success
? <span className="text-green-600 font-medium">OK</span>
: <span className="text-red-600 font-medium">FAIL</span>}
</td>
</tr>,
isOpen && (
<tr key={`${key}-detail`}>
<td colSpan={6} className="p-0">
<EntryDetail entry={entry} />
</td>
</tr>
),
];
})}
</tbody>
</table>
</div>
);
}
export function AuditLog() {
const [tab, setTab] = useState<Tab>('all');
const [page, setPage] = useState(0);
const [filters, setFilters] = useState<AuditLogQuery>({ 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 (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-900">Audit Log</h1>
<span className="text-sm text-gray-400">Auto-refreshes every 30s</span>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200">
{(['all', 'security', 'user'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors ${
tab === t
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{t === 'all' ? 'All Events' : t === 'security' ? 'Security Events' : 'User Activity'}
</button>
))}
</div>
{/* All Events tab filters */}
{tab === 'all' && (
<form onSubmit={handleSearch} className="flex flex-wrap gap-3 mb-4">
<input
type="text"
placeholder="Search…"
value={search}
onChange={e => 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"
/>
<select
value={actionFilter}
onChange={e => { setActionFilter(e.target.value); setPage(0); }}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="">All actions</option>
{Object.entries(ACTION_GROUPS).map(([group, actions]) => (
<optgroup key={group} label={group}>
{actions.map(a => <option key={a} value={a}>{a}</option>)}
</optgroup>
))}
</select>
<select
value={severityFilter}
onChange={e => { setSeverityFilter(e.target.value); setPage(0); }}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="">All severities</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="critical">Critical</option>
</select>
<select
value={successFilter}
onChange={e => { setSuccessFilter(e.target.value); setPage(0); }}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="">Success + Failures</option>
<option value="true">Success only</option>
<option value="false">Failures only</option>
</select>
<button
type="submit"
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Search
</button>
{(search || actionFilter || severityFilter || successFilter) && (
<button
type="button"
onClick={() => { setSearch(''); setActionFilter(''); setSeverityFilter(''); setSuccessFilter(''); setPage(0); }}
className="px-4 py-1.5 text-sm border border-gray-300 rounded-md text-gray-600 hover:bg-gray-50"
>
Clear
</button>
)}
</form>
)}
{/* Security tab controls */}
{tab === 'security' && (
<div className="flex items-center gap-3 mb-4">
<label className="text-sm text-gray-600">Last</label>
<select
value={securityHours}
onChange={e => setSecurityHours(Number(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"
>
<option value={1}>1 hour</option>
<option value={6}>6 hours</option>
<option value={24}>24 hours</option>
<option value={72}>72 hours</option>
</select>
</div>
)}
{/* User activity tab controls */}
{tab === 'user' && (
<form
className="flex gap-3 mb-4"
onSubmit={e => { e.preventDefault(); setActiveUserId(userIdInput.trim()); }}
>
<input
type="text"
placeholder="User ID or email"
value={userIdInput}
onChange={e => 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"
/>
<button
type="submit"
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Load
</button>
</form>
)}
{/* Results */}
{tab === 'all' && (
<>
{allQuery.data && (
<p className="text-sm text-gray-500 mb-2">
{allQuery.data.total_count.toLocaleString()} events total
</p>
)}
<AuditTable logs={allQuery.data?.logs ?? []} isLoading={allQuery.isLoading} />
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<button
disabled={page === 0}
onClick={() => setPage(p => p - 1)}
className="px-4 py-1.5 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50"
>
Previous
</button>
<span className="text-sm text-gray-500">Page {page + 1} of {totalPages}</span>
<button
disabled={!allQuery.data?.has_more}
onClick={() => setPage(p => p + 1)}
className="px-4 py-1.5 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50"
>
Next
</button>
</div>
)}
</>
)}
{tab === 'security' && (
<AuditTable logs={securityQuery.data ?? []} isLoading={securityQuery.isLoading} />
)}
{tab === 'user' && (
activeUserId
? <AuditTable logs={userQuery.data ?? []} isLoading={userQuery.isLoading} />
: <p className="text-gray-500 text-center py-8">Enter a user ID above and click Load.</p>
)}
</div>
);
}

View file

@ -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() {
</div>
<p className="mt-1 text-xs text-gray-400">
Links this job to a project in the{' '}
<a href="https://cost.oliver.agency" target="_blank" rel="noreferrer" className="underline">AI Cost Dashboard</a>.
<a href={COST_TRACKER_DASHBOARD_URL} target="_blank" rel="noreferrer" className="underline">AI Cost Dashboard</a>.
</p>
</div>

View file

@ -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() {
</div>
<p className="mt-1 text-xs text-gray-400">
Links this job to a project in the{' '}
<a href="https://cost.oliver.agency" target="_blank" rel="noreferrer" className="underline">AI Cost Dashboard</a>.
<a href={COST_TRACKER_DASHBOARD_URL} target="_blank" rel="noreferrer" className="underline">AI Cost Dashboard</a>.
</p>
</div>

View file

@ -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() {
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<div className="flex items-center gap-3">
<a
href="https://cost.oliver.agency"
href={COST_TRACKER_DASHBOARD_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"

View file

@ -586,4 +586,54 @@ export interface TTSRegenerationQueueRequest {
export interface RerenderAccessibleVideoRequest {
whisper_refine: boolean;
}
// ── Audit Log ──────────────────────────────────────────────────────────────
export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical';
export interface AuditLogEntry {
id?: string;
timestamp: string;
action: string;
severity: AuditSeverity;
description: string;
user_id?: string;
user_email?: string;
user_role?: string;
ip_address?: string;
user_agent?: string;
request_id?: string;
resource_type?: string;
resource_id?: string;
resource_name?: string;
details?: Record<string, unknown>;
success: boolean;
error_message?: string;
environment?: string;
}
export interface AuditLogListResponse {
logs: AuditLogEntry[];
total_count: number;
page: number;
page_size: number;
has_more: boolean;
}
export interface AuditLogQuery {
action?: string;
severity?: AuditSeverity;
user_id?: string;
user_email?: string;
resource_type?: string;
resource_id?: string;
success?: boolean;
search?: string;
start_date?: string;
end_date?: string;
skip?: number;
limit?: number;
sort_by?: string;
sort_order?: number;
}