Spaces:
Sleeping
feat: immutable audit log for forensic chain-of-custody
Browse filesBackend:
- AuditLog model (append-only, no UPDATE/DELETE) with action enum,
user snapshot, resource type/id, detail, ip_address
- log_event() helper — silently swallows errors so logging never
breaks the main request flow
- Integrated in all key endpoints: login, project create/delete,
document upload/delete, all analysis types, PDF download, clear analyses
- GET /audit/ (admin only) with filters by action and user email,
pagination (page/page_size)
Frontend:
- AdminPage with two tabs: Users and Activity Log
- Audit tab: filter by action and email, paginated table with
color-coded action badges, timestamp, user, resource, detail, IP
- auditApi in api.ts
- i18n keys for audit log (IT + EN)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- backend/audit.py +54 -0
- backend/main.py +2 -1
- backend/models/__init__.py +1 -0
- backend/models/audit.py +62 -0
- backend/routers/analysis.py +38 -9
- backend/routers/audit.py +68 -0
- backend/routers/auth.py +9 -1
- backend/routers/projects.py +10 -0
- frontend/src/App.tsx +2 -0
- frontend/src/i18n/en.json +25 -1
- frontend/src/i18n/it.json +25 -1
- frontend/src/lib/api.ts +27 -0
- frontend/src/pages/AdminPage.tsx +297 -0
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GraphoLab Backend — Audit log helper.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
from backend.audit import log_event
|
| 6 |
+
from backend.models.audit import AuditAction
|
| 7 |
+
|
| 8 |
+
await log_event(
|
| 9 |
+
db = db,
|
| 10 |
+
user = current_user,
|
| 11 |
+
action = AuditAction.analysis_run,
|
| 12 |
+
resource_type = "analysis",
|
| 13 |
+
resource_id = analysis.id,
|
| 14 |
+
detail = "pipeline",
|
| 15 |
+
ip_address = request.client.host if request.client else None,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
The function simply inserts a row and flushes — the caller's db session
|
| 19 |
+
commits at the end of the request as usual.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 25 |
+
|
| 26 |
+
from backend.models.audit import AuditAction, AuditLog
|
| 27 |
+
from backend.models.user import User
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def log_event(
|
| 31 |
+
db: AsyncSession,
|
| 32 |
+
user: User,
|
| 33 |
+
action: AuditAction,
|
| 34 |
+
resource_type: str | None = None,
|
| 35 |
+
resource_id: int | None = None,
|
| 36 |
+
detail: str | None = None,
|
| 37 |
+
ip_address: str | None = None,
|
| 38 |
+
) -> None:
|
| 39 |
+
"""Append one row to the audit_log table. Never raises — failures are silently swallowed
|
| 40 |
+
so that a logging error never breaks an otherwise successful operation."""
|
| 41 |
+
try:
|
| 42 |
+
entry = AuditLog(
|
| 43 |
+
user_id=user.id,
|
| 44 |
+
user_email=user.email,
|
| 45 |
+
action=action,
|
| 46 |
+
resource_type=resource_type,
|
| 47 |
+
resource_id=resource_id,
|
| 48 |
+
detail=detail,
|
| 49 |
+
ip_address=ip_address,
|
| 50 |
+
)
|
| 51 |
+
db.add(entry)
|
| 52 |
+
await db.flush()
|
| 53 |
+
except Exception:
|
| 54 |
+
pass # audit must never break the main flow
|
|
@@ -18,7 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 18 |
|
| 19 |
from backend.config import settings
|
| 20 |
from backend.database import init_db
|
| 21 |
-
from backend.routers import auth, users, projects, analysis, rag
|
| 22 |
|
| 23 |
|
| 24 |
@asynccontextmanager
|
|
@@ -51,6 +51,7 @@ app.include_router(users.router)
|
|
| 51 |
app.include_router(projects.router)
|
| 52 |
app.include_router(analysis.router)
|
| 53 |
app.include_router(rag.router)
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
# ── Health check ──────────────────────────────────────────────────────────────
|
|
|
|
| 18 |
|
| 19 |
from backend.config import settings
|
| 20 |
from backend.database import init_db
|
| 21 |
+
from backend.routers import auth, users, projects, analysis, rag, audit
|
| 22 |
|
| 23 |
|
| 24 |
@asynccontextmanager
|
|
|
|
| 51 |
app.include_router(projects.router)
|
| 52 |
app.include_router(analysis.router)
|
| 53 |
app.include_router(rag.router)
|
| 54 |
+
app.include_router(audit.router)
|
| 55 |
|
| 56 |
|
| 57 |
# ── Health check ──────────────────────────────────────────────────────────────
|
|
@@ -1,3 +1,4 @@
|
|
| 1 |
# Import all models so SQLAlchemy can resolve relationships regardless of import order.
|
| 2 |
from backend.models.user import User, Organization, Role # noqa: F401
|
| 3 |
from backend.models.project import Project, Document, Analysis, ProjectStatus, AnalysisType # noqa: F401
|
|
|
|
|
|
| 1 |
# Import all models so SQLAlchemy can resolve relationships regardless of import order.
|
| 2 |
from backend.models.user import User, Organization, Role # noqa: F401
|
| 3 |
from backend.models.project import Project, Document, Analysis, ProjectStatus, AnalysisType # noqa: F401
|
| 4 |
+
from backend.models.audit import AuditLog, AuditAction # noqa: F401
|
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GraphoLab Backend — AuditLog ORM model.
|
| 3 |
+
|
| 4 |
+
Append-only table: no UPDATE or DELETE is ever issued by application code.
|
| 5 |
+
Every sensitive operation is recorded here for forensic chain-of-custody.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import enum
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func
|
| 14 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 15 |
+
|
| 16 |
+
from backend.database import Base
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AuditAction(str, enum.Enum):
|
| 20 |
+
login = "login"
|
| 21 |
+
project_create = "project_create"
|
| 22 |
+
project_delete = "project_delete"
|
| 23 |
+
document_upload = "document_upload"
|
| 24 |
+
document_delete = "document_delete"
|
| 25 |
+
analysis_run = "analysis_run"
|
| 26 |
+
analysis_clear = "analysis_clear"
|
| 27 |
+
pdf_download = "pdf_download"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AuditLog(Base):
|
| 31 |
+
"""Immutable audit trail. Never UPDATE or DELETE rows from this table."""
|
| 32 |
+
|
| 33 |
+
__tablename__ = "audit_log"
|
| 34 |
+
|
| 35 |
+
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
| 36 |
+
|
| 37 |
+
# UTC timestamp — server-generated, not client-supplied
|
| 38 |
+
timestamp: Mapped[datetime] = mapped_column(
|
| 39 |
+
DateTime(timezone=True), server_default=func.now(), index=True
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Who performed the action (snapshot: user may be deleted later)
|
| 43 |
+
user_id: Mapped[int | None] = mapped_column(
|
| 44 |
+
Integer,
|
| 45 |
+
ForeignKey("users.id", ondelete="SET NULL"),
|
| 46 |
+
nullable=True,
|
| 47 |
+
index=True,
|
| 48 |
+
)
|
| 49 |
+
user_email: Mapped[str] = mapped_column(String(256), nullable=False)
|
| 50 |
+
|
| 51 |
+
# What happened
|
| 52 |
+
action: Mapped[AuditAction] = mapped_column(Enum(AuditAction), nullable=False, index=True)
|
| 53 |
+
|
| 54 |
+
# On which resource (e.g. resource_type="project", resource_id=42)
|
| 55 |
+
resource_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
| 56 |
+
resource_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 57 |
+
|
| 58 |
+
# Optional extra context (e.g. analysis type, filename)
|
| 59 |
+
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 60 |
+
|
| 61 |
+
# Network info
|
| 62 |
+
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
@@ -33,9 +33,11 @@ from pydantic import BaseModel
|
|
| 33 |
from sqlalchemy import delete, select
|
| 34 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 35 |
|
|
|
|
| 36 |
from backend.auth.dependencies import get_current_user
|
| 37 |
from backend.config import settings
|
| 38 |
from backend.database import get_db
|
|
|
|
| 39 |
from backend.models.project import Analysis, AnalysisType, Document, Project
|
| 40 |
from backend.models.user import Role, User
|
| 41 |
from backend.storage.minio_client import download_object, upload_fileobj
|
|
@@ -150,7 +152,10 @@ async def run_htr(
|
|
| 150 |
from core.ocr import htr_transcribe
|
| 151 |
text = htr_transcribe(image)
|
| 152 |
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
@router.post("/signature-detection", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
@@ -166,9 +171,12 @@ async def run_signature_detection(
|
|
| 166 |
from core.signature import detect_and_crop
|
| 167 |
annotated, _, summary = detect_and_crop(image)
|
| 168 |
|
| 169 |
-
|
| 170 |
db, body.project_id, doc.id, AnalysisType.signature_detection, summary, annotated
|
| 171 |
)
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
@router.post("/signature-verification", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
@@ -190,9 +198,12 @@ async def run_signature_verification(
|
|
| 190 |
|
| 191 |
report, _ = sig_verify(ref_image, None, query, settings.signet_weights)
|
| 192 |
|
| 193 |
-
|
| 194 |
db, body.project_id, doc.id, AnalysisType.signature_verification, report
|
| 195 |
)
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
|
| 198 |
@router.post("/ner", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
@@ -210,7 +221,10 @@ async def run_ner(
|
|
| 210 |
text = htr_transcribe(image)
|
| 211 |
_, ner_summary = ner_extract(text)
|
| 212 |
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
|
| 216 |
@router.post("/writer", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
@@ -226,9 +240,12 @@ async def run_writer_identification(
|
|
| 226 |
from core.writer import writer_identify
|
| 227 |
report, _ = writer_identify(image, settings.writer_samples_dir)
|
| 228 |
|
| 229 |
-
|
| 230 |
db, body.project_id, doc.id, AnalysisType.writer_identification, report
|
| 231 |
)
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
|
| 234 |
@router.post("/graphology", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
@@ -244,9 +261,12 @@ async def run_graphology(
|
|
| 244 |
from core.graphology import grapho_analyse
|
| 245 |
report, annotated = grapho_analyse(image)
|
| 246 |
|
| 247 |
-
|
| 248 |
db, body.project_id, doc.id, AnalysisType.graphology, report, annotated
|
| 249 |
)
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
|
| 252 |
@router.post("/dating", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
@@ -264,11 +284,14 @@ async def run_dating(
|
|
| 264 |
text = htr_transcribe(image)
|
| 265 |
dates = extract_dates(text)
|
| 266 |
if dates:
|
| 267 |
-
|
| 268 |
else:
|
| 269 |
-
|
| 270 |
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
|
| 274 |
@router.post("/pipeline", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
@@ -331,6 +354,8 @@ async def run_pipeline(
|
|
| 331 |
analysis.result_text = analysis.result_text.replace("__img_sig__", f"/api/analysis/{analysis.id}/image/sig") if analysis.result_text else analysis.result_text
|
| 332 |
await db.flush()
|
| 333 |
|
|
|
|
|
|
|
| 334 |
return analysis
|
| 335 |
|
| 336 |
|
|
@@ -357,6 +382,8 @@ async def clear_analyses(
|
|
| 357 |
) -> None:
|
| 358 |
await _check_project_access(project_id, db, current_user)
|
| 359 |
await db.execute(delete(Analysis).where(Analysis.project_id == project_id))
|
|
|
|
|
|
|
| 360 |
await db.commit()
|
| 361 |
|
| 362 |
|
|
@@ -414,6 +441,8 @@ async def get_analysis_pdf(
|
|
| 414 |
if not analysis.result_text:
|
| 415 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nessun testo disponibile.")
|
| 416 |
|
|
|
|
|
|
|
| 417 |
pdf_bytes = await anyio.to_thread.run_sync(lambda: _generate_pdf(analysis))
|
| 418 |
return StreamingResponse(
|
| 419 |
io.BytesIO(pdf_bytes),
|
|
|
|
| 33 |
from sqlalchemy import delete, select
|
| 34 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 35 |
|
| 36 |
+
from backend.audit import log_event
|
| 37 |
from backend.auth.dependencies import get_current_user
|
| 38 |
from backend.config import settings
|
| 39 |
from backend.database import get_db
|
| 40 |
+
from backend.models.audit import AuditAction
|
| 41 |
from backend.models.project import Analysis, AnalysisType, Document, Project
|
| 42 |
from backend.models.user import Role, User
|
| 43 |
from backend.storage.minio_client import download_object, upload_fileobj
|
|
|
|
| 152 |
from core.ocr import htr_transcribe
|
| 153 |
text = htr_transcribe(image)
|
| 154 |
|
| 155 |
+
result = await _save_analysis(db, body.project_id, doc.id, AnalysisType.htr, text)
|
| 156 |
+
await log_event(db, current_user, AuditAction.analysis_run,
|
| 157 |
+
resource_type="analysis", resource_id=result.id, detail="htr")
|
| 158 |
+
return result
|
| 159 |
|
| 160 |
|
| 161 |
@router.post("/signature-detection", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
|
|
| 171 |
from core.signature import detect_and_crop
|
| 172 |
annotated, _, summary = detect_and_crop(image)
|
| 173 |
|
| 174 |
+
result = await _save_analysis(
|
| 175 |
db, body.project_id, doc.id, AnalysisType.signature_detection, summary, annotated
|
| 176 |
)
|
| 177 |
+
await log_event(db, current_user, AuditAction.analysis_run,
|
| 178 |
+
resource_type="analysis", resource_id=result.id, detail="signature_detection")
|
| 179 |
+
return result
|
| 180 |
|
| 181 |
|
| 182 |
@router.post("/signature-verification", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
|
|
| 198 |
|
| 199 |
report, _ = sig_verify(ref_image, None, query, settings.signet_weights)
|
| 200 |
|
| 201 |
+
result = await _save_analysis(
|
| 202 |
db, body.project_id, doc.id, AnalysisType.signature_verification, report
|
| 203 |
)
|
| 204 |
+
await log_event(db, current_user, AuditAction.analysis_run,
|
| 205 |
+
resource_type="analysis", resource_id=result.id, detail="signature_verification")
|
| 206 |
+
return result
|
| 207 |
|
| 208 |
|
| 209 |
@router.post("/ner", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
|
|
| 221 |
text = htr_transcribe(image)
|
| 222 |
_, ner_summary = ner_extract(text)
|
| 223 |
|
| 224 |
+
result = await _save_analysis(db, body.project_id, doc.id, AnalysisType.ner, ner_summary)
|
| 225 |
+
await log_event(db, current_user, AuditAction.analysis_run,
|
| 226 |
+
resource_type="analysis", resource_id=result.id, detail="ner")
|
| 227 |
+
return result
|
| 228 |
|
| 229 |
|
| 230 |
@router.post("/writer", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
|
|
| 240 |
from core.writer import writer_identify
|
| 241 |
report, _ = writer_identify(image, settings.writer_samples_dir)
|
| 242 |
|
| 243 |
+
result = await _save_analysis(
|
| 244 |
db, body.project_id, doc.id, AnalysisType.writer_identification, report
|
| 245 |
)
|
| 246 |
+
await log_event(db, current_user, AuditAction.analysis_run,
|
| 247 |
+
resource_type="analysis", resource_id=result.id, detail="writer_identification")
|
| 248 |
+
return result
|
| 249 |
|
| 250 |
|
| 251 |
@router.post("/graphology", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
|
|
| 261 |
from core.graphology import grapho_analyse
|
| 262 |
report, annotated = grapho_analyse(image)
|
| 263 |
|
| 264 |
+
result = await _save_analysis(
|
| 265 |
db, body.project_id, doc.id, AnalysisType.graphology, report, annotated
|
| 266 |
)
|
| 267 |
+
await log_event(db, current_user, AuditAction.analysis_run,
|
| 268 |
+
resource_type="analysis", resource_id=result.id, detail="graphology")
|
| 269 |
+
return result
|
| 270 |
|
| 271 |
|
| 272 |
@router.post("/dating", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
|
|
| 284 |
text = htr_transcribe(image)
|
| 285 |
dates = extract_dates(text)
|
| 286 |
if dates:
|
| 287 |
+
dating_result = "\n".join(f"- {raw} → {dt.strftime('%Y-%m-%d')}" for raw, dt in dates)
|
| 288 |
else:
|
| 289 |
+
dating_result = "Nessuna data rilevata nel documento."
|
| 290 |
|
| 291 |
+
saved = await _save_analysis(db, body.project_id, doc.id, AnalysisType.dating, dating_result)
|
| 292 |
+
await log_event(db, current_user, AuditAction.analysis_run,
|
| 293 |
+
resource_type="analysis", resource_id=saved.id, detail="dating")
|
| 294 |
+
return saved
|
| 295 |
|
| 296 |
|
| 297 |
@router.post("/pipeline", response_model=AnalysisOut, status_code=status.HTTP_201_CREATED)
|
|
|
|
| 354 |
analysis.result_text = analysis.result_text.replace("__img_sig__", f"/api/analysis/{analysis.id}/image/sig") if analysis.result_text else analysis.result_text
|
| 355 |
await db.flush()
|
| 356 |
|
| 357 |
+
await log_event(db, current_user, AuditAction.analysis_run,
|
| 358 |
+
resource_type="analysis", resource_id=analysis.id, detail="pipeline")
|
| 359 |
return analysis
|
| 360 |
|
| 361 |
|
|
|
|
| 382 |
) -> None:
|
| 383 |
await _check_project_access(project_id, db, current_user)
|
| 384 |
await db.execute(delete(Analysis).where(Analysis.project_id == project_id))
|
| 385 |
+
await log_event(db, current_user, AuditAction.analysis_clear,
|
| 386 |
+
resource_type="project", resource_id=project_id)
|
| 387 |
await db.commit()
|
| 388 |
|
| 389 |
|
|
|
|
| 441 |
if not analysis.result_text:
|
| 442 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nessun testo disponibile.")
|
| 443 |
|
| 444 |
+
await log_event(db, current_user, AuditAction.pdf_download,
|
| 445 |
+
resource_type="analysis", resource_id=analysis_id)
|
| 446 |
pdf_bytes = await anyio.to_thread.run_sync(lambda: _generate_pdf(analysis))
|
| 447 |
return StreamingResponse(
|
| 448 |
io.BytesIO(pdf_bytes),
|
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GraphoLab Backend — Audit log router.
|
| 3 |
+
|
| 4 |
+
Endpoints:
|
| 5 |
+
GET /audit → paginated audit log (admin only)
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from sqlalchemy import select, func
|
| 15 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 16 |
+
|
| 17 |
+
from backend.auth.dependencies import get_current_user
|
| 18 |
+
from backend.database import get_db
|
| 19 |
+
from backend.models.audit import AuditAction, AuditLog
|
| 20 |
+
from backend.models.user import Role, User
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/audit", tags=["audit"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class AuditLogOut(BaseModel):
|
| 26 |
+
id: int
|
| 27 |
+
timestamp: datetime
|
| 28 |
+
user_id: int | None
|
| 29 |
+
user_email: str
|
| 30 |
+
action: AuditAction
|
| 31 |
+
resource_type: str | None
|
| 32 |
+
resource_id: int | None
|
| 33 |
+
detail: str | None
|
| 34 |
+
ip_address: str | None
|
| 35 |
+
|
| 36 |
+
model_config = {"from_attributes": True}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class AuditPage(BaseModel):
|
| 40 |
+
total: int
|
| 41 |
+
items: list[AuditLogOut]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.get("/", response_model=AuditPage)
|
| 45 |
+
async def list_audit_log(
|
| 46 |
+
page: int = Query(1, ge=1),
|
| 47 |
+
page_size: int = Query(50, ge=1, le=200),
|
| 48 |
+
action: AuditAction | None = Query(None),
|
| 49 |
+
user_email: str | None = Query(None),
|
| 50 |
+
db: AsyncSession = Depends(get_db),
|
| 51 |
+
current_user: User = Depends(get_current_user),
|
| 52 |
+
) -> AuditPage:
|
| 53 |
+
if current_user.role != Role.admin:
|
| 54 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Solo gli amministratori possono visualizzare il log.")
|
| 55 |
+
|
| 56 |
+
q = select(AuditLog)
|
| 57 |
+
if action:
|
| 58 |
+
q = q.where(AuditLog.action == action)
|
| 59 |
+
if user_email:
|
| 60 |
+
q = q.where(AuditLog.user_email.ilike(f"%{user_email}%"))
|
| 61 |
+
|
| 62 |
+
count_q = select(func.count()).select_from(q.subquery())
|
| 63 |
+
total = (await db.execute(count_q)).scalar_one()
|
| 64 |
+
|
| 65 |
+
q = q.order_by(AuditLog.timestamp.desc()).offset((page - 1) * page_size).limit(page_size)
|
| 66 |
+
rows = (await db.execute(q)).scalars().all()
|
| 67 |
+
|
| 68 |
+
return AuditPage(total=total, items=list(rows))
|
|
@@ -9,16 +9,18 @@ Endpoints:
|
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 13 |
from fastapi.security import OAuth2PasswordRequestForm
|
| 14 |
from jose import JWTError
|
| 15 |
from pydantic import BaseModel
|
| 16 |
from sqlalchemy import select
|
| 17 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
|
|
|
|
| 19 |
from backend.auth.jwt import TokenType, create_access_token, create_refresh_token, decode_token
|
| 20 |
from backend.auth.password import verify_password
|
| 21 |
from backend.database import get_db
|
|
|
|
| 22 |
from backend.models.user import User
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
@@ -36,6 +38,7 @@ class RefreshRequest(BaseModel):
|
|
| 36 |
|
| 37 |
@router.post("/login", response_model=TokenResponse)
|
| 38 |
async def login(
|
|
|
|
| 39 |
form: OAuth2PasswordRequestForm = Depends(),
|
| 40 |
db: AsyncSession = Depends(get_db),
|
| 41 |
) -> TokenResponse:
|
|
@@ -54,6 +57,11 @@ async def login(
|
|
| 54 |
detail="Account disabilitato. Contatta l'amministratore.",
|
| 55 |
)
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
return TokenResponse(
|
| 58 |
access_token=create_access_token(user.id, user.role),
|
| 59 |
refresh_token=create_refresh_token(user.id),
|
|
|
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 13 |
from fastapi.security import OAuth2PasswordRequestForm
|
| 14 |
from jose import JWTError
|
| 15 |
from pydantic import BaseModel
|
| 16 |
from sqlalchemy import select
|
| 17 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
|
| 19 |
+
from backend.audit import log_event
|
| 20 |
from backend.auth.jwt import TokenType, create_access_token, create_refresh_token, decode_token
|
| 21 |
from backend.auth.password import verify_password
|
| 22 |
from backend.database import get_db
|
| 23 |
+
from backend.models.audit import AuditAction
|
| 24 |
from backend.models.user import User
|
| 25 |
|
| 26 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
| 38 |
|
| 39 |
@router.post("/login", response_model=TokenResponse)
|
| 40 |
async def login(
|
| 41 |
+
request: Request,
|
| 42 |
form: OAuth2PasswordRequestForm = Depends(),
|
| 43 |
db: AsyncSession = Depends(get_db),
|
| 44 |
) -> TokenResponse:
|
|
|
|
| 57 |
detail="Account disabilitato. Contatta l'amministratore.",
|
| 58 |
)
|
| 59 |
|
| 60 |
+
await log_event(
|
| 61 |
+
db, user, AuditAction.login,
|
| 62 |
+
ip_address=request.client.host if request.client else None,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
return TokenResponse(
|
| 66 |
access_token=create_access_token(user.id, user.role),
|
| 67 |
refresh_token=create_refresh_token(user.id),
|
|
@@ -22,8 +22,10 @@ from sqlalchemy import select
|
|
| 22 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 23 |
from sqlalchemy.orm import selectinload
|
| 24 |
|
|
|
|
| 25 |
from backend.auth.dependencies import get_current_user
|
| 26 |
from backend.database import get_db
|
|
|
|
| 27 |
from backend.models.project import Document, Project, ProjectStatus
|
| 28 |
from backend.models.user import Role, User
|
| 29 |
from backend.storage.minio_client import delete_object, upload_fileobj
|
|
@@ -122,6 +124,8 @@ async def create_project(
|
|
| 122 |
)
|
| 123 |
db.add(project)
|
| 124 |
await db.flush()
|
|
|
|
|
|
|
| 125 |
project.document_count = 0
|
| 126 |
return project
|
| 127 |
|
|
@@ -163,6 +167,8 @@ async def delete_project(
|
|
| 163 |
current_user: User = Depends(get_current_user),
|
| 164 |
) -> None:
|
| 165 |
project = await _get_project_or_404(project_id, db, current_user)
|
|
|
|
|
|
|
| 166 |
# Remove files from MinIO
|
| 167 |
for doc in project.documents:
|
| 168 |
await delete_object(doc.storage_key)
|
|
@@ -195,6 +201,8 @@ async def upload_document(
|
|
| 195 |
)
|
| 196 |
db.add(doc)
|
| 197 |
await db.flush()
|
|
|
|
|
|
|
| 198 |
return doc
|
| 199 |
|
| 200 |
|
|
@@ -222,5 +230,7 @@ async def delete_document(
|
|
| 222 |
doc = result.scalar_one_or_none()
|
| 223 |
if doc is None:
|
| 224 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento non trovato.")
|
|
|
|
|
|
|
| 225 |
await delete_object(doc.storage_key)
|
| 226 |
await db.delete(doc)
|
|
|
|
| 22 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 23 |
from sqlalchemy.orm import selectinload
|
| 24 |
|
| 25 |
+
from backend.audit import log_event
|
| 26 |
from backend.auth.dependencies import get_current_user
|
| 27 |
from backend.database import get_db
|
| 28 |
+
from backend.models.audit import AuditAction
|
| 29 |
from backend.models.project import Document, Project, ProjectStatus
|
| 30 |
from backend.models.user import Role, User
|
| 31 |
from backend.storage.minio_client import delete_object, upload_fileobj
|
|
|
|
| 124 |
)
|
| 125 |
db.add(project)
|
| 126 |
await db.flush()
|
| 127 |
+
await log_event(db, current_user, AuditAction.project_create,
|
| 128 |
+
resource_type="project", resource_id=project.id, detail=body.title)
|
| 129 |
project.document_count = 0
|
| 130 |
return project
|
| 131 |
|
|
|
|
| 167 |
current_user: User = Depends(get_current_user),
|
| 168 |
) -> None:
|
| 169 |
project = await _get_project_or_404(project_id, db, current_user)
|
| 170 |
+
await log_event(db, current_user, AuditAction.project_delete,
|
| 171 |
+
resource_type="project", resource_id=project_id, detail=project.title)
|
| 172 |
# Remove files from MinIO
|
| 173 |
for doc in project.documents:
|
| 174 |
await delete_object(doc.storage_key)
|
|
|
|
| 201 |
)
|
| 202 |
db.add(doc)
|
| 203 |
await db.flush()
|
| 204 |
+
await log_event(db, current_user, AuditAction.document_upload,
|
| 205 |
+
resource_type="document", resource_id=doc.id, detail=file.filename)
|
| 206 |
return doc
|
| 207 |
|
| 208 |
|
|
|
|
| 230 |
doc = result.scalar_one_or_none()
|
| 231 |
if doc is None:
|
| 232 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento non trovato.")
|
| 233 |
+
await log_event(db, current_user, AuditAction.document_delete,
|
| 234 |
+
resource_type="document", resource_id=doc_id, detail=doc.filename)
|
| 235 |
await delete_object(doc.storage_key)
|
| 236 |
await db.delete(doc)
|
|
@@ -4,6 +4,7 @@ import LoginPage from "@/pages/LoginPage"
|
|
| 4 |
import ProjectsPage from "@/pages/ProjectsPage"
|
| 5 |
import ProjectDetailPage from "@/pages/ProjectDetailPage"
|
| 6 |
import RagPage from "@/pages/RagPage"
|
|
|
|
| 7 |
|
| 8 |
export default function App() {
|
| 9 |
return (
|
|
@@ -14,6 +15,7 @@ export default function App() {
|
|
| 14 |
<Route path="/projects" element={<ProjectsPage />} />
|
| 15 |
<Route path="/projects/:id" element={<ProjectDetailPage />} />
|
| 16 |
<Route path="/rag" element={<RagPage />} />
|
|
|
|
| 17 |
</Route>
|
| 18 |
<Route path="*" element={<Navigate to="/projects" replace />} />
|
| 19 |
</Routes>
|
|
|
|
| 4 |
import ProjectsPage from "@/pages/ProjectsPage"
|
| 5 |
import ProjectDetailPage from "@/pages/ProjectDetailPage"
|
| 6 |
import RagPage from "@/pages/RagPage"
|
| 7 |
+
import AdminPage from "@/pages/AdminPage"
|
| 8 |
|
| 9 |
export default function App() {
|
| 10 |
return (
|
|
|
|
| 15 |
<Route path="/projects" element={<ProjectsPage />} />
|
| 16 |
<Route path="/projects/:id" element={<ProjectDetailPage />} />
|
| 17 |
<Route path="/rag" element={<RagPage />} />
|
| 18 |
+
<Route path="/admin/*" element={<AdminPage />} />
|
| 19 |
</Route>
|
| 20 |
<Route path="*" element={<Navigate to="/projects" replace />} />
|
| 21 |
</Routes>
|
|
@@ -81,6 +81,7 @@
|
|
| 81 |
},
|
| 82 |
"admin": {
|
| 83 |
"users": "Users",
|
|
|
|
| 84 |
"new_user": "New user",
|
| 85 |
"email": "Email",
|
| 86 |
"full_name": "Full name",
|
|
@@ -92,7 +93,30 @@
|
|
| 92 |
"admin": "Administrator",
|
| 93 |
"examiner": "Examiner",
|
| 94 |
"viewer": "Read-only"
|
| 95 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
},
|
| 97 |
"common": {
|
| 98 |
"loading": "Loading…",
|
|
|
|
| 81 |
},
|
| 82 |
"admin": {
|
| 83 |
"users": "Users",
|
| 84 |
+
"audit": "Activity log",
|
| 85 |
"new_user": "New user",
|
| 86 |
"email": "Email",
|
| 87 |
"full_name": "Full name",
|
|
|
|
| 93 |
"admin": "Administrator",
|
| 94 |
"examiner": "Examiner",
|
| 95 |
"viewer": "Read-only"
|
| 96 |
+
},
|
| 97 |
+
"audit_timestamp": "Date/Time",
|
| 98 |
+
"audit_user": "User",
|
| 99 |
+
"audit_action": "Action",
|
| 100 |
+
"audit_resource": "Resource",
|
| 101 |
+
"audit_detail": "Detail",
|
| 102 |
+
"audit_ip": "IP",
|
| 103 |
+
"audit_actions": {
|
| 104 |
+
"login": "Login",
|
| 105 |
+
"project_create": "Case created",
|
| 106 |
+
"project_delete": "Case deleted",
|
| 107 |
+
"document_upload": "Document uploaded",
|
| 108 |
+
"document_delete": "Document deleted",
|
| 109 |
+
"analysis_run": "Analysis run",
|
| 110 |
+
"analysis_clear": "Analyses cleared",
|
| 111 |
+
"pdf_download": "PDF downloaded"
|
| 112 |
+
},
|
| 113 |
+
"audit_empty": "No activity recorded.",
|
| 114 |
+
"audit_filter_action": "Filter by action",
|
| 115 |
+
"audit_filter_user": "Filter by email",
|
| 116 |
+
"audit_all_actions": "All actions",
|
| 117 |
+
"audit_prev": "Previous",
|
| 118 |
+
"audit_next": "Next",
|
| 119 |
+
"audit_page": "Page {{page}} — {{total}} events"
|
| 120 |
},
|
| 121 |
"common": {
|
| 122 |
"loading": "Loading…",
|
|
@@ -81,6 +81,7 @@
|
|
| 81 |
},
|
| 82 |
"admin": {
|
| 83 |
"users": "Utenti",
|
|
|
|
| 84 |
"new_user": "Nuovo utente",
|
| 85 |
"email": "Email",
|
| 86 |
"full_name": "Nome completo",
|
|
@@ -92,7 +93,30 @@
|
|
| 92 |
"admin": "Amministratore",
|
| 93 |
"examiner": "Perito",
|
| 94 |
"viewer": "Sola lettura"
|
| 95 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
},
|
| 97 |
"common": {
|
| 98 |
"loading": "Caricamento…",
|
|
|
|
| 81 |
},
|
| 82 |
"admin": {
|
| 83 |
"users": "Utenti",
|
| 84 |
+
"audit": "Registro attività",
|
| 85 |
"new_user": "Nuovo utente",
|
| 86 |
"email": "Email",
|
| 87 |
"full_name": "Nome completo",
|
|
|
|
| 93 |
"admin": "Amministratore",
|
| 94 |
"examiner": "Perito",
|
| 95 |
"viewer": "Sola lettura"
|
| 96 |
+
},
|
| 97 |
+
"audit_timestamp": "Data/Ora",
|
| 98 |
+
"audit_user": "Utente",
|
| 99 |
+
"audit_action": "Azione",
|
| 100 |
+
"audit_resource": "Risorsa",
|
| 101 |
+
"audit_detail": "Dettaglio",
|
| 102 |
+
"audit_ip": "IP",
|
| 103 |
+
"audit_actions": {
|
| 104 |
+
"login": "Login",
|
| 105 |
+
"project_create": "Perizia creata",
|
| 106 |
+
"project_delete": "Perizia eliminata",
|
| 107 |
+
"document_upload": "Documento caricato",
|
| 108 |
+
"document_delete": "Documento eliminato",
|
| 109 |
+
"analysis_run": "Analisi eseguita",
|
| 110 |
+
"analysis_clear": "Analisi cancellate",
|
| 111 |
+
"pdf_download": "PDF scaricato"
|
| 112 |
+
},
|
| 113 |
+
"audit_empty": "Nessuna attività registrata.",
|
| 114 |
+
"audit_filter_action": "Filtra per azione",
|
| 115 |
+
"audit_filter_user": "Filtra per email",
|
| 116 |
+
"audit_all_actions": "Tutte le azioni",
|
| 117 |
+
"audit_prev": "Precedente",
|
| 118 |
+
"audit_next": "Successivo",
|
| 119 |
+
"audit_page": "Pagina {{page}} — {{total}} eventi"
|
| 120 |
},
|
| 121 |
"common": {
|
| 122 |
"loading": "Caricamento…",
|
|
@@ -161,6 +161,33 @@ export const analysisApi = {
|
|
| 161 |
imageUrl: (analysisId: number) => `/api/analysis/${analysisId}/image`,
|
| 162 |
}
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
// RAG
|
| 165 |
export const ragApi = {
|
| 166 |
status: () => api.get<{ ollama_reachable: boolean; models: string[] }>("/rag/status"),
|
|
|
|
| 161 |
imageUrl: (analysisId: number) => `/api/analysis/${analysisId}/image`,
|
| 162 |
}
|
| 163 |
|
| 164 |
+
// Audit
|
| 165 |
+
export interface AuditLogEntry {
|
| 166 |
+
id: number
|
| 167 |
+
timestamp: string
|
| 168 |
+
user_id: number | null
|
| 169 |
+
user_email: string
|
| 170 |
+
action: string
|
| 171 |
+
resource_type: string | null
|
| 172 |
+
resource_id: number | null
|
| 173 |
+
detail: string | null
|
| 174 |
+
ip_address: string | null
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
export interface AuditPage {
|
| 178 |
+
total: number
|
| 179 |
+
items: AuditLogEntry[]
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
export const auditApi = {
|
| 183 |
+
list: (page = 1, pageSize = 50, action?: string, userEmail?: string) => {
|
| 184 |
+
const params: Record<string, string | number> = { page, page_size: pageSize }
|
| 185 |
+
if (action) params.action = action
|
| 186 |
+
if (userEmail) params.user_email = userEmail
|
| 187 |
+
return api.get<AuditPage>("/audit/", { params })
|
| 188 |
+
},
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
// RAG
|
| 192 |
export const ragApi = {
|
| 193 |
status: () => api.get<{ ollama_reachable: boolean; models: string[] }>("/rag/status"),
|
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react"
|
| 2 |
+
import { useTranslation } from "react-i18next"
|
| 3 |
+
import { Users, ClipboardList, Plus, Trash2, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"
|
| 4 |
+
import { Button } from "@/components/ui/button"
|
| 5 |
+
import { Badge } from "@/components/ui/badge"
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 7 |
+
import { Input } from "@/components/ui/input"
|
| 8 |
+
import { api, usersApi, auditApi, type User, type AuditLogEntry } from "@/lib/api"
|
| 9 |
+
|
| 10 |
+
const AUDIT_ACTIONS = [
|
| 11 |
+
"login",
|
| 12 |
+
"project_create",
|
| 13 |
+
"project_delete",
|
| 14 |
+
"document_upload",
|
| 15 |
+
"document_delete",
|
| 16 |
+
"analysis_run",
|
| 17 |
+
"analysis_clear",
|
| 18 |
+
"pdf_download",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
const ACTION_COLORS: Record<string, string> = {
|
| 22 |
+
login: "bg-blue-100 text-blue-800",
|
| 23 |
+
project_create: "bg-green-100 text-green-800",
|
| 24 |
+
project_delete: "bg-red-100 text-red-800",
|
| 25 |
+
document_upload: "bg-green-100 text-green-800",
|
| 26 |
+
document_delete: "bg-red-100 text-red-800",
|
| 27 |
+
analysis_run: "bg-purple-100 text-purple-800",
|
| 28 |
+
analysis_clear: "bg-orange-100 text-orange-800",
|
| 29 |
+
pdf_download: "bg-gray-100 text-gray-800",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export default function AdminPage() {
|
| 33 |
+
const { t } = useTranslation()
|
| 34 |
+
const [tab, setTab] = useState<"users" | "audit">("users")
|
| 35 |
+
|
| 36 |
+
// ── Users state ──────────────────────────────────────────────────────────────
|
| 37 |
+
const [users, setUsers] = useState<User[]>([])
|
| 38 |
+
const [newEmail, setNewEmail] = useState("")
|
| 39 |
+
const [newName, setNewName] = useState("")
|
| 40 |
+
const [newPassword, setNewPassword] = useState("")
|
| 41 |
+
const [newRole, setNewRole] = useState<"admin" | "examiner" | "viewer">("examiner")
|
| 42 |
+
const [userError, setUserError] = useState("")
|
| 43 |
+
|
| 44 |
+
async function loadUsers() {
|
| 45 |
+
const { data } = await usersApi.list()
|
| 46 |
+
setUsers(data)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async function handleCreateUser(e: React.FormEvent) {
|
| 50 |
+
e.preventDefault()
|
| 51 |
+
setUserError("")
|
| 52 |
+
try {
|
| 53 |
+
const { data } = await usersApi.create({ email: newEmail, full_name: newName, password: newPassword, role: newRole })
|
| 54 |
+
setUsers((u) => [...u, data])
|
| 55 |
+
setNewEmail(""); setNewName(""); setNewPassword(""); setNewRole("examiner")
|
| 56 |
+
} catch (err: any) {
|
| 57 |
+
setUserError(err?.response?.data?.detail ?? t("common.error"))
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function handleDeactivate(userId: number) {
|
| 62 |
+
if (!confirm(t("common.confirm"))) return
|
| 63 |
+
await usersApi.deactivate(userId)
|
| 64 |
+
setUsers((u) => u.map((x) => x.id === userId ? { ...x, is_active: false } : x))
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// ── Audit state ──────────────────────────────────────────────────────────────
|
| 68 |
+
const [auditItems, setAuditItems] = useState<AuditLogEntry[]>([])
|
| 69 |
+
const [auditTotal, setAuditTotal] = useState(0)
|
| 70 |
+
const [auditPage, setAuditPage] = useState(1)
|
| 71 |
+
const [filterAction, setFilterAction] = useState("")
|
| 72 |
+
const [filterEmail, setFilterEmail] = useState("")
|
| 73 |
+
const PAGE_SIZE = 50
|
| 74 |
+
|
| 75 |
+
async function loadAudit(page = auditPage) {
|
| 76 |
+
const { data } = await auditApi.list(page, PAGE_SIZE, filterAction || undefined, filterEmail || undefined)
|
| 77 |
+
setAuditItems(data.items)
|
| 78 |
+
setAuditTotal(data.total)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
useEffect(() => { loadUsers() }, [])
|
| 82 |
+
useEffect(() => { if (tab === "audit") loadAudit(1) }, [tab])
|
| 83 |
+
|
| 84 |
+
function handleAuditFilter(e: React.FormEvent) {
|
| 85 |
+
e.preventDefault()
|
| 86 |
+
setAuditPage(1)
|
| 87 |
+
loadAudit(1)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function changePage(delta: number) {
|
| 91 |
+
const next = auditPage + delta
|
| 92 |
+
setAuditPage(next)
|
| 93 |
+
loadAudit(next)
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const totalPages = Math.max(1, Math.ceil(auditTotal / PAGE_SIZE))
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div className="p-6 max-w-5xl mx-auto space-y-4">
|
| 100 |
+
<h1 className="text-xl font-semibold">{t("nav.admin")}</h1>
|
| 101 |
+
|
| 102 |
+
{/* Tab bar */}
|
| 103 |
+
<div className="flex gap-1 border-b pb-0">
|
| 104 |
+
<button
|
| 105 |
+
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === "users" ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
| 106 |
+
onClick={() => setTab("users")}
|
| 107 |
+
>
|
| 108 |
+
<Users className="h-4 w-4" />
|
| 109 |
+
{t("admin.users")}
|
| 110 |
+
</button>
|
| 111 |
+
<button
|
| 112 |
+
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === "audit" ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
| 113 |
+
onClick={() => setTab("audit")}
|
| 114 |
+
>
|
| 115 |
+
<ClipboardList className="h-4 w-4" />
|
| 116 |
+
{t("admin.audit")}
|
| 117 |
+
</button>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
{/* ── Users tab ─────────────────────────────────────────────────────────── */}
|
| 121 |
+
{tab === "users" && (
|
| 122 |
+
<div className="space-y-4">
|
| 123 |
+
{/* Create user form */}
|
| 124 |
+
<Card>
|
| 125 |
+
<CardHeader className="pb-2">
|
| 126 |
+
<CardTitle className="text-sm flex items-center gap-2">
|
| 127 |
+
<Plus className="h-4 w-4" />
|
| 128 |
+
{t("admin.new_user")}
|
| 129 |
+
</CardTitle>
|
| 130 |
+
</CardHeader>
|
| 131 |
+
<CardContent>
|
| 132 |
+
<form onSubmit={handleCreateUser} className="flex flex-wrap gap-2 items-end">
|
| 133 |
+
<div className="flex flex-col gap-1">
|
| 134 |
+
<label className="text-xs text-muted-foreground">{t("admin.email")}</label>
|
| 135 |
+
<Input className="h-8 text-sm w-48" type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} required />
|
| 136 |
+
</div>
|
| 137 |
+
<div className="flex flex-col gap-1">
|
| 138 |
+
<label className="text-xs text-muted-foreground">{t("admin.full_name")}</label>
|
| 139 |
+
<Input className="h-8 text-sm w-40" value={newName} onChange={(e) => setNewName(e.target.value)} required />
|
| 140 |
+
</div>
|
| 141 |
+
<div className="flex flex-col gap-1">
|
| 142 |
+
<label className="text-xs text-muted-foreground">Password</label>
|
| 143 |
+
<Input className="h-8 text-sm w-32" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
|
| 144 |
+
</div>
|
| 145 |
+
<div className="flex flex-col gap-1">
|
| 146 |
+
<label className="text-xs text-muted-foreground">{t("admin.role")}</label>
|
| 147 |
+
<select className="h-8 text-sm border rounded-md px-2" value={newRole} onChange={(e) => setNewRole(e.target.value as any)}>
|
| 148 |
+
<option value="examiner">{t("admin.roles.examiner")}</option>
|
| 149 |
+
<option value="viewer">{t("admin.roles.viewer")}</option>
|
| 150 |
+
<option value="admin">{t("admin.roles.admin")}</option>
|
| 151 |
+
</select>
|
| 152 |
+
</div>
|
| 153 |
+
<Button type="submit" size="sm" className="h-8">{t("admin.new_user")}</Button>
|
| 154 |
+
</form>
|
| 155 |
+
{userError && <p className="text-xs text-destructive mt-2">{userError}</p>}
|
| 156 |
+
</CardContent>
|
| 157 |
+
</Card>
|
| 158 |
+
|
| 159 |
+
{/* Users table */}
|
| 160 |
+
<Card>
|
| 161 |
+
<CardContent className="pt-4">
|
| 162 |
+
<table className="w-full text-sm">
|
| 163 |
+
<thead>
|
| 164 |
+
<tr className="border-b text-xs text-muted-foreground">
|
| 165 |
+
<th className="text-left pb-2 font-medium">{t("admin.full_name")}</th>
|
| 166 |
+
<th className="text-left pb-2 font-medium">{t("admin.email")}</th>
|
| 167 |
+
<th className="text-left pb-2 font-medium">{t("admin.role")}</th>
|
| 168 |
+
<th className="text-left pb-2 font-medium">{t("admin.status")}</th>
|
| 169 |
+
<th />
|
| 170 |
+
</tr>
|
| 171 |
+
</thead>
|
| 172 |
+
<tbody>
|
| 173 |
+
{users.map((u) => (
|
| 174 |
+
<tr key={u.id} className="border-b last:border-0">
|
| 175 |
+
<td className="py-2">{u.full_name}</td>
|
| 176 |
+
<td className="py-2 text-muted-foreground">{u.email}</td>
|
| 177 |
+
<td className="py-2">
|
| 178 |
+
<Badge variant="outline" className="text-xs">{t(`admin.roles.${u.role}`)}</Badge>
|
| 179 |
+
</td>
|
| 180 |
+
<td className="py-2">
|
| 181 |
+
<Badge variant={u.is_active ? "default" : "secondary"} className="text-xs">
|
| 182 |
+
{u.is_active ? t("admin.active") : t("admin.inactive")}
|
| 183 |
+
</Badge>
|
| 184 |
+
</td>
|
| 185 |
+
<td className="py-2 text-right">
|
| 186 |
+
{u.is_active && (
|
| 187 |
+
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
| 188 |
+
onClick={() => handleDeactivate(u.id)}>
|
| 189 |
+
<Trash2 className="h-3.5 w-3.5" />
|
| 190 |
+
</Button>
|
| 191 |
+
)}
|
| 192 |
+
</td>
|
| 193 |
+
</tr>
|
| 194 |
+
))}
|
| 195 |
+
</tbody>
|
| 196 |
+
</table>
|
| 197 |
+
</CardContent>
|
| 198 |
+
</Card>
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
|
| 202 |
+
{/* ── Audit tab ─────────────────────────────────────────────────────────── */}
|
| 203 |
+
{tab === "audit" && (
|
| 204 |
+
<div className="space-y-3">
|
| 205 |
+
{/* Filters */}
|
| 206 |
+
<form onSubmit={handleAuditFilter} className="flex flex-wrap gap-2 items-end">
|
| 207 |
+
<div className="flex flex-col gap-1">
|
| 208 |
+
<label className="text-xs text-muted-foreground">{t("admin.audit_filter_action")}</label>
|
| 209 |
+
<select className="h-8 text-sm border rounded-md px-2 w-44"
|
| 210 |
+
value={filterAction} onChange={(e) => setFilterAction(e.target.value)}>
|
| 211 |
+
<option value="">{t("admin.audit_all_actions")}</option>
|
| 212 |
+
{AUDIT_ACTIONS.map((a) => (
|
| 213 |
+
<option key={a} value={a}>{t(`admin.audit_actions.${a}`)}</option>
|
| 214 |
+
))}
|
| 215 |
+
</select>
|
| 216 |
+
</div>
|
| 217 |
+
<div className="flex flex-col gap-1">
|
| 218 |
+
<label className="text-xs text-muted-foreground">{t("admin.audit_filter_user")}</label>
|
| 219 |
+
<Input className="h-8 text-sm w-48" placeholder="email…"
|
| 220 |
+
value={filterEmail} onChange={(e) => setFilterEmail(e.target.value)} />
|
| 221 |
+
</div>
|
| 222 |
+
<Button type="submit" size="sm" className="h-8 gap-1">
|
| 223 |
+
<RefreshCw className="h-3.5 w-3.5" />
|
| 224 |
+
{t("common.confirm")}
|
| 225 |
+
</Button>
|
| 226 |
+
</form>
|
| 227 |
+
|
| 228 |
+
{/* Summary */}
|
| 229 |
+
{auditTotal > 0 && (
|
| 230 |
+
<p className="text-xs text-muted-foreground">
|
| 231 |
+
{t("admin.audit_page", { page: auditPage, total: auditTotal })}
|
| 232 |
+
</p>
|
| 233 |
+
)}
|
| 234 |
+
|
| 235 |
+
{/* Table */}
|
| 236 |
+
<Card>
|
| 237 |
+
<CardContent className="pt-4 overflow-x-auto">
|
| 238 |
+
{auditItems.length === 0 ? (
|
| 239 |
+
<p className="text-sm text-muted-foreground">{t("admin.audit_empty")}</p>
|
| 240 |
+
) : (
|
| 241 |
+
<table className="w-full text-xs">
|
| 242 |
+
<thead>
|
| 243 |
+
<tr className="border-b text-muted-foreground">
|
| 244 |
+
<th className="text-left pb-2 font-medium">{t("admin.audit_timestamp")}</th>
|
| 245 |
+
<th className="text-left pb-2 font-medium">{t("admin.audit_user")}</th>
|
| 246 |
+
<th className="text-left pb-2 font-medium">{t("admin.audit_action")}</th>
|
| 247 |
+
<th className="text-left pb-2 font-medium">{t("admin.audit_resource")}</th>
|
| 248 |
+
<th className="text-left pb-2 font-medium">{t("admin.audit_detail")}</th>
|
| 249 |
+
<th className="text-left pb-2 font-medium">{t("admin.audit_ip")}</th>
|
| 250 |
+
</tr>
|
| 251 |
+
</thead>
|
| 252 |
+
<tbody>
|
| 253 |
+
{auditItems.map((entry) => (
|
| 254 |
+
<tr key={entry.id} className="border-b last:border-0">
|
| 255 |
+
<td className="py-1.5 pr-3 whitespace-nowrap text-muted-foreground">
|
| 256 |
+
{new Date(entry.timestamp).toLocaleString()}
|
| 257 |
+
</td>
|
| 258 |
+
<td className="py-1.5 pr-3">{entry.user_email}</td>
|
| 259 |
+
<td className="py-1.5 pr-3">
|
| 260 |
+
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${ACTION_COLORS[entry.action] ?? "bg-gray-100 text-gray-800"}`}>
|
| 261 |
+
{t(`admin.audit_actions.${entry.action}`, { defaultValue: entry.action })}
|
| 262 |
+
</span>
|
| 263 |
+
</td>
|
| 264 |
+
<td className="py-1.5 pr-3 text-muted-foreground">
|
| 265 |
+
{entry.resource_type ? `${entry.resource_type}/${entry.resource_id}` : "—"}
|
| 266 |
+
</td>
|
| 267 |
+
<td className="py-1.5 pr-3">{entry.detail ?? "—"}</td>
|
| 268 |
+
<td className="py-1.5 text-muted-foreground">{entry.ip_address ?? "—"}</td>
|
| 269 |
+
</tr>
|
| 270 |
+
))}
|
| 271 |
+
</tbody>
|
| 272 |
+
</table>
|
| 273 |
+
)}
|
| 274 |
+
</CardContent>
|
| 275 |
+
</Card>
|
| 276 |
+
|
| 277 |
+
{/* Pagination */}
|
| 278 |
+
{totalPages > 1 && (
|
| 279 |
+
<div className="flex items-center gap-2 justify-end">
|
| 280 |
+
<Button variant="outline" size="sm" className="h-7 gap-1"
|
| 281 |
+
disabled={auditPage <= 1} onClick={() => changePage(-1)}>
|
| 282 |
+
<ChevronLeft className="h-3.5 w-3.5" />
|
| 283 |
+
{t("admin.audit_prev")}
|
| 284 |
+
</Button>
|
| 285 |
+
<span className="text-xs text-muted-foreground">{auditPage} / {totalPages}</span>
|
| 286 |
+
<Button variant="outline" size="sm" className="h-7 gap-1"
|
| 287 |
+
disabled={auditPage >= totalPages} onClick={() => changePage(1)}>
|
| 288 |
+
{t("admin.audit_next")}
|
| 289 |
+
<ChevronRight className="h-3.5 w-3.5" />
|
| 290 |
+
</Button>
|
| 291 |
+
</div>
|
| 292 |
+
)}
|
| 293 |
+
</div>
|
| 294 |
+
)}
|
| 295 |
+
</div>
|
| 296 |
+
)
|
| 297 |
+
}
|