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:
parent
0f15d192cb
commit
09550cfca0
13 changed files with 556 additions and 11 deletions
|
|
@ -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"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
2
frontend/src/lib/costTracker.ts
Normal file
2
frontend/src/lib/costTracker.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const COST_TRACKER_DASHBOARD_URL =
|
||||
'https://optical-dev.oliver.solutions/cost-tracker/analytics';
|
||||
347
frontend/src/routes/admin/AuditLog.tsx
Normal file
347
frontend/src/routes/admin/AuditLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue