diff --git a/backend/app/main.py b/backend/app/main.py index c29c2ad..535c64c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,8 +6,9 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.database import Base, SessionLocal, engine from app.migrations import run_migrations +from app.models.audit_log import AuditLog # noqa: F401 from app.models.verification_token import VerificationToken # noqa: F401 -from app.routers import auth, documents, templates, users +from app.routers import auth, documents, logs, templates, users from app.seed import seed_admin_user Base.metadata.create_all(bind=engine) @@ -35,6 +36,7 @@ app.add_middleware( app.include_router(auth.router) app.include_router(users.router) +app.include_router(logs.router) app.include_router(templates.router) app.include_router(documents.router) diff --git a/backend/app/migrations.py b/backend/app/migrations.py index df501e9..55e282d 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -60,3 +60,55 @@ def run_migrations() -> None: "ON verification_tokens (code)" ) ) + + if "audit_logs" not in tables: + with engine.begin() as conn: + if dialect == "postgresql": + conn.execute( + text( + """ + CREATE TABLE audit_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + username VARCHAR(50), + action VARCHAR(100) NOT NULL, + resource_type VARCHAR(50), + resource_id INTEGER, + details JSON, + ip_address VARCHAR(45), + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """ + ) + ) + else: + conn.execute( + text( + """ + CREATE TABLE audit_logs ( + id INTEGER NOT NULL PRIMARY KEY, + user_id INTEGER, + username VARCHAR(50), + action VARCHAR(100) NOT NULL, + resource_type VARCHAR(50), + resource_id INTEGER, + details JSON, + ip_address VARCHAR(45), + created_at DATETIME NOT NULL, + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL + ) + """ + ) + ) + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at " + "ON audit_logs (created_at DESC)" + ) + ) + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_audit_logs_action " + "ON audit_logs (action)" + ) + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9d80e28..6d2d111 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.document import FilledDocument from app.models.template import DocumentTemplate, TemplateVariable from app.models.user import User, UserRole from app.models.verification_token import TokenType, VerificationToken +from app.models.audit_log import AuditLog __all__ = [ "User", @@ -11,4 +12,5 @@ __all__ = [ "FilledDocument", "VerificationToken", "TokenType", + "AuditLog", ] diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..2071c44 --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + username: Mapped[str | None] = mapped_column(String(100), nullable=True) + action: Mapped[str] = mapped_column(String(100), index=True) + resource_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + resource_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + details: Mapped[str | None] = mapped_column(Text, nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, index=True + ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 2b1ad34..00508c1 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session @@ -25,6 +25,7 @@ from app.schemas import ( UserCreate, UserResponse, ) +from app.services.audit import get_client_ip, log_action from app.services.email_service import send_activation_email, send_password_reset_email from app.services.verification import create_verification_token, verify_code @@ -32,7 +33,11 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) @router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED) -def register(user_data: UserCreate, db: Annotated[Session, Depends(get_db)]): +def register( + user_data: UserCreate, + request: Request, + db: Annotated[Session, Depends(get_db)], +): if user_data.password != user_data.confirm_password: raise HTTPException(status_code=400, detail="Passwords do not match") @@ -52,6 +57,15 @@ def register(user_data: UserCreate, db: Annotated[Session, Depends(get_db)]): db.flush() token = create_verification_token(db, user, TokenType.activation) + log_action( + db, + "auth.register", + user=user, + resource_type="user", + resource_id=user.id, + details={"email": user.email}, + ip_address=get_client_ip(request), + ) db.commit() send_activation_email(user.email, user.username, token.code) @@ -65,6 +79,7 @@ def register(user_data: UserCreate, db: Annotated[Session, Depends(get_db)]): @router.post("/activate", response_model=MessageResponse) def activate_account( data: ActivateAccountRequest, + request: Request, db: Annotated[Session, Depends(get_db)], ): user = verify_code(db, data.email, data.code, TokenType.activation) @@ -72,6 +87,14 @@ def activate_account( raise HTTPException(status_code=400, detail="Invalid or expired activation code") user.is_active = True + log_action( + db, + "auth.activate", + user=user, + resource_type="user", + resource_id=user.id, + ip_address=get_client_ip(request), + ) db.commit() return MessageResponse(message="Account activated. You can now log in.") @@ -80,6 +103,7 @@ def activate_account( @router.post("/resend-activation", response_model=MessageResponse) def resend_activation( data: ForgotPasswordRequest, + request: Request, db: Annotated[Session, Depends(get_db)], ): user = get_user_by_email(db, data.email) @@ -92,6 +116,14 @@ def resend_activation( raise HTTPException(status_code=400, detail="Account is already activated") token = create_verification_token(db, user, TokenType.activation) + log_action( + db, + "auth.resend_activation", + user=user, + resource_type="user", + resource_id=user.id, + ip_address=get_client_ip(request), + ) db.commit() send_activation_email(user.email, user.username, token.code) @@ -103,11 +135,20 @@ def resend_activation( @router.post("/forgot-password", response_model=MessageResponse) def forgot_password( data: ForgotPasswordRequest, + request: Request, db: Annotated[Session, Depends(get_db)], ): user = get_user_by_email(db, data.email) if user and user.is_active: token = create_verification_token(db, user, TokenType.password_reset) + log_action( + db, + "auth.forgot_password", + user=user, + resource_type="user", + resource_id=user.id, + ip_address=get_client_ip(request), + ) db.commit() send_password_reset_email(user.email, user.username, token.code) @@ -119,6 +160,7 @@ def forgot_password( @router.post("/reset-password", response_model=MessageResponse) def reset_password( data: ResetPasswordRequest, + request: Request, db: Annotated[Session, Depends(get_db)], ): if data.password != data.confirm_password: @@ -129,6 +171,14 @@ def reset_password( raise HTTPException(status_code=400, detail="Invalid or expired reset code") user.hashed_password = get_password_hash(data.password) + log_action( + db, + "auth.reset_password", + user=user, + resource_type="user", + resource_id=user.id, + ip_address=get_client_ip(request), + ) db.commit() return MessageResponse(message="Password updated. You can now log in.") @@ -136,6 +186,7 @@ def reset_password( @router.post("/login", response_model=Token) def login( + request: Request, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(get_db)], ): @@ -151,6 +202,15 @@ def login( status_code=status.HTTP_403_FORBIDDEN, detail="Account not activated. Check your email for the activation code.", ) + log_action( + db, + "auth.login", + user=user, + resource_type="user", + resource_id=user.id, + ip_address=get_client_ip(request), + ) + db.commit() token = create_access_token(data={"sub": user.username}) return Token(access_token=token) diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index 8542034..0dfa7d4 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -3,7 +3,7 @@ import os import uuid from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import FileResponse from sqlalchemy.orm import Session, joinedload @@ -20,6 +20,7 @@ from app.schemas import ( PreviewResponse, ) from app.services.access import accessible_document, accessible_template, is_admin +from app.services.audit import get_client_ip, log_action from app.services.docx_renderer import docx_to_html, export_to_pdf, render_docx router = APIRouter(prefix="/api/documents", tags=["documents"]) @@ -90,6 +91,7 @@ def get_document( def update_document( document_id: int, update: FilledDocumentUpdate, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ): @@ -114,6 +116,15 @@ def update_document( raise HTTPException(status_code=404, detail="Template not found") _rerender_document(doc, template) + log_action( + db, + "document.update", + user=current_user, + resource_type="document", + resource_id=doc.id, + details={"name": doc.name}, + ip_address=get_client_ip(request), + ) db.commit() db.refresh(doc) return _doc_to_response(doc) @@ -122,6 +133,7 @@ def update_document( @router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_document( document_id: int, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ): @@ -129,6 +141,16 @@ def delete_document( if not doc or not accessible_document(db, current_user, document_id): raise HTTPException(status_code=404, detail="Document not found") + log_action( + db, + "document.delete", + user=current_user, + resource_type="document", + resource_id=doc.id, + details={"name": doc.name}, + ip_address=get_client_ip(request), + ) + if doc.rendered_docx_path and os.path.exists(doc.rendered_docx_path): os.remove(doc.rendered_docx_path) @@ -139,6 +161,7 @@ def delete_document( @router.post("", response_model=FilledDocumentResponse, status_code=status.HTTP_201_CREATED) def create_document( data: FilledDocumentCreate, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ): @@ -158,6 +181,16 @@ def create_document( rendered_docx_path=output_path, ) db.add(doc) + db.flush() + log_action( + db, + "document.create", + user=current_user, + resource_type="document", + resource_id=doc.id, + details={"name": doc.name, "template_id": doc.template_id}, + ip_address=get_client_ip(request), + ) db.commit() db.refresh(doc) return _doc_to_response(doc) @@ -186,6 +219,7 @@ def preview_document( @router.get("/{document_id}/export/docx") def export_docx( document_id: int, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ): @@ -204,6 +238,17 @@ def export_docx( if not doc.rendered_docx_path: raise HTTPException(status_code=404, detail="Rendered file not found") + log_action( + db, + "document.export_docx", + user=current_user, + resource_type="document", + resource_id=doc.id, + details={"name": doc.name}, + ip_address=get_client_ip(request), + ) + db.commit() + return FileResponse( doc.rendered_docx_path, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", @@ -214,6 +259,7 @@ def export_docx( @router.get("/{document_id}/export/pdf") def export_pdf( document_id: int, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ): @@ -240,4 +286,15 @@ def export_pdf( except RuntimeError as e: raise HTTPException(status_code=500, detail=str(e)) + log_action( + db, + "document.export_pdf", + user=current_user, + resource_type="document", + resource_id=doc.id, + details={"name": doc.name}, + ip_address=get_client_ip(request), + ) + db.commit() + return FileResponse(pdf_path, media_type="application/pdf", filename=f"{doc.name}.pdf") diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py new file mode 100644 index 0000000..d954a21 --- /dev/null +++ b/backend/app/routers/logs.py @@ -0,0 +1,48 @@ +import json +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.auth import get_current_admin +from app.database import get_db +from app.models.audit_log import AuditLog +from app.models.user import User +from app.schemas import AuditLogResponse + +router = APIRouter(prefix="/api/logs", tags=["logs"]) + + +def _log_to_response(log: AuditLog) -> AuditLogResponse: + details = None + if log.details: + try: + details = json.loads(log.details) + except json.JSONDecodeError: + details = {"raw": log.details} + return AuditLogResponse( + id=log.id, + user_id=log.user_id, + username=log.username, + action=log.action, + resource_type=log.resource_type, + resource_id=log.resource_id, + details=details, + ip_address=log.ip_address, + created_at=log.created_at, + ) + + +@router.get("", response_model=list[AuditLogResponse]) +def list_logs( + db: Annotated[Session, Depends(get_db)], + _: Annotated[User, Depends(get_current_admin)], + limit: int = Query(default=200, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + action: str | None = None, +): + query = db.query(AuditLog).order_by(AuditLog.created_at.desc()) + if action: + query = query.filter(AuditLog.action.contains(action)) + logs = query.offset(offset).limit(limit).all() + return [_log_to_response(log) for log in logs] diff --git a/backend/app/routers/templates.py b/backend/app/routers/templates.py index 94b14ec..7f256dd 100644 --- a/backend/app/routers/templates.py +++ b/backend/app/routers/templates.py @@ -3,7 +3,7 @@ import os import uuid from typing import Annotated -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 FileResponse from sqlalchemy.orm import Session, joinedload @@ -26,6 +26,7 @@ from app.services.access import ( is_admin, manageable_template, ) +from app.services.audit import get_client_ip, log_action from app.services.docx_parser import parse_docx_template router = APIRouter(prefix="/api/templates", tags=["templates"]) @@ -124,6 +125,7 @@ def get_template( @router.get("/{template_id}/download") def download_template_source( template_id: int, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ): @@ -133,6 +135,17 @@ def download_template_source( if not os.path.exists(template.file_path): raise HTTPException(status_code=404, detail="Source file not found") + log_action( + db, + "template.download", + user=current_user, + resource_type="template", + resource_id=template.id, + details={"name": template.name}, + ip_address=get_client_ip(request), + ) + db.commit() + return FileResponse( template.file_path, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", @@ -144,6 +157,7 @@ def download_template_source( def update_template( template_id: int, update: TemplateUpdate, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ): @@ -151,9 +165,19 @@ def update_template( if not template: raise HTTPException(status_code=404, detail="Template not found") - for field, value in update.model_dump(exclude_unset=True).items(): + changes = update.model_dump(exclude_unset=True) + for field, value in changes.items(): setattr(template, field, value) + log_action( + db, + "template.update", + user=current_user, + resource_type="template", + resource_id=template.id, + details={"name": template.name, "changes": changes}, + ip_address=get_client_ip(request), + ) db.commit() db.refresh(template) return _template_to_response(template, current_user) @@ -161,6 +185,7 @@ def update_template( @router.post("", response_model=DocumentTemplateResponse, status_code=status.HTTP_201_CREATED) async def create_template( + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], file: UploadFile = File(...), @@ -207,6 +232,15 @@ async def create_template( ) ) + log_action( + db, + "template.create", + user=current_user, + resource_type="template", + resource_id=template.id, + details={"name": template.name, "is_public": template.is_public}, + ip_address=get_client_ip(request), + ) db.commit() db.refresh(template) return _template_to_response(template, current_user) @@ -260,6 +294,7 @@ def update_variable( @router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_template( template_id: int, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ): @@ -267,6 +302,16 @@ def delete_template( if not template: raise HTTPException(status_code=404, detail="Template not found") + log_action( + db, + "template.delete", + user=current_user, + resource_type="template", + resource_id=template.id, + details={"name": template.name}, + ip_address=get_client_ip(request), + ) + if os.path.exists(template.file_path): os.remove(template.file_path) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index ab6e118..0e770d4 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,12 +1,13 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session from app.auth import get_current_admin, get_current_user, get_password_hash from app.database import get_db from app.models.user import User, UserRole from app.schemas import AdminUserCreate, UserResponse, UserUpdate +from app.services.audit import get_client_ip, log_action from app.services.user_cleanup import cleanup_user_files router = APIRouter(prefix="/api/users", tags=["users"]) @@ -27,8 +28,9 @@ def list_users( @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) def create_user( user_data: AdminUserCreate, + request: Request, db: Annotated[Session, Depends(get_db)], - _: Annotated[User, Depends(get_current_admin)], + current_user: Annotated[User, Depends(get_current_admin)], ): from app.auth import get_user_by_email, get_user_by_username @@ -45,6 +47,16 @@ def create_user( is_active=True, ) db.add(user) + db.flush() + log_action( + db, + "user.create", + user=current_user, + resource_type="user", + resource_id=user.id, + details={"username": user.username, "email": user.email, "role": user.role.value}, + ip_address=get_client_ip(request), + ) db.commit() db.refresh(user) return user @@ -54,6 +66,7 @@ def create_user( def update_user( user_id: int, update: UserUpdate, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_admin)], ): @@ -77,9 +90,11 @@ def update_user( if _count_admins(db) <= 1: raise HTTPException(status_code=400, detail="Cannot deactivate the last admin") + changes: dict = {} if update.email is not None and update.email != user.email: if get_user_by_email(db, update.email): raise HTTPException(status_code=400, detail="Email already registered") + changes["email"] = {"from": user.email, "to": update.email} user.email = update.email if update.username is not None and update.username != user.username: @@ -87,15 +102,40 @@ def update_user( if get_user_by_username(db, update.username): raise HTTPException(status_code=400, detail="Username already taken") + changes["username"] = {"from": user.username, "to": update.username} user.username = update.username - if update.role is not None: + if update.role is not None and update.role != user.role: + changes["role"] = {"from": user.role.value, "to": update.role.value} user.role = update.role - if update.is_active is not None: + + if update.is_active is not None and update.is_active != user.is_active: user.is_active = update.is_active + log_action( + db, + "user.activate" if update.is_active else "user.deactivate", + user=current_user, + resource_type="user", + resource_id=user.id, + details={"target_username": user.username}, + ip_address=get_client_ip(request), + ) + if update.password: + changes["password"] = "changed" user.hashed_password = get_password_hash(update.password) + if changes: + log_action( + db, + "user.update", + user=current_user, + resource_type="user", + resource_id=user.id, + details={"target_username": user.username, "changes": changes}, + ip_address=get_client_ip(request), + ) + db.commit() db.refresh(user) return user @@ -104,6 +144,7 @@ def update_user( @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_user( user_id: int, + request: Request, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_admin)], ): @@ -117,6 +158,15 @@ def delete_user( if user.role == UserRole.admin and _count_admins(db) <= 1: raise HTTPException(status_code=400, detail="Cannot delete the last admin") + log_action( + db, + "user.delete", + user=current_user, + resource_type="user", + resource_id=user.id, + details={"username": user.username, "email": user.email}, + ip_address=get_client_ip(request), + ) cleanup_user_files(db, user) db.delete(user) db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6555898..3499214 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -38,6 +38,20 @@ class MessageResponse(BaseModel): message: str +class AuditLogResponse(BaseModel): + id: int + user_id: int | None + username: str | None + action: str + resource_type: str | None + resource_id: int | None + details: dict[str, Any] | None = None + ip_address: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + class AdminUserCreate(BaseModel): email: EmailStr username: str = Field(min_length=3, max_length=100) diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..72b073b --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,41 @@ +import json +from typing import Any + +from fastapi import Request +from sqlalchemy.orm import Session + +from app.models.audit_log import AuditLog +from app.models.user import User + + +def get_client_ip(request: Request | None) -> str | None: + if request is None: + return None + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return None + + +def log_action( + db: Session, + action: str, + *, + user: User | None = None, + resource_type: str | None = None, + resource_id: int | None = None, + details: dict[str, Any] | None = None, + ip_address: str | None = None, +) -> None: + entry = AuditLog( + user_id=user.id if user else None, + username=user.username if user else None, + action=action, + resource_type=resource_type, + resource_id=resource_id, + details=json.dumps(details, ensure_ascii=False) if details else None, + ip_address=ip_address, + ) + db.add(entry) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8e925d..fbc8bc5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import ResetPasswordPage from './pages/ResetPasswordPage' import TemplateDetailPage from './pages/TemplateDetailPage' import TemplatesPage from './pages/TemplatesPage' import UsersPage from './pages/UsersPage' +import LogsPage from './pages/LogsPage' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth() @@ -43,6 +44,7 @@ function AppRoutes() { } /> } /> } /> + } /> ) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3c5234a..d1d5567 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,5 @@ import type { + AuditLog, DocumentTemplate, FilledDocument, PreviewResponse, @@ -164,6 +165,15 @@ export const api = { return request(`/users/${id}`, { method: 'DELETE' }) }, + async listLogs(params?: { limit?: number; offset?: number; action?: string }): Promise { + const search = new URLSearchParams() + if (params?.limit != null) search.set('limit', String(params.limit)) + if (params?.offset != null) search.set('offset', String(params.offset)) + if (params?.action) search.set('action', params.action) + const query = search.toString() + return request(`/logs${query ? `?${query}` : ''}`) + }, + async listTemplates(): Promise { return request('/templates') }, diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..5bbd18f --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,52 @@ +interface ConfirmDialogProps { + open: boolean + title: string + message: string + confirmLabel?: string + cancelLabel?: string + danger?: boolean + onConfirm: () => void + onCancel: () => void +} + +export default function ConfirmDialog({ + open, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + danger = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + if (!open) return null + + return ( +
+
e.stopPropagation()} + > +

+ {title} +

+

{message}

+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 410fd11..cc70194 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -21,9 +21,14 @@ export default function Navbar() { Documents {user?.role === 'admin' && ( - - Users - + <> + + Users + + + Logs + + )} {user && ( <> diff --git a/frontend/src/index.css b/frontend/src/index.css index 2214066..0b92599 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -378,3 +378,30 @@ textarea { background: #dcfce7; color: var(--success); } + +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 1000; +} + +.dialog { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 24px; + width: 100%; + max-width: 440px; +} + +.log-action { + font-size: 0.85rem; + background: #e8eef7; + padding: 2px 8px; + border-radius: 4px; +} diff --git a/frontend/src/pages/DocumentDetailPage.tsx b/frontend/src/pages/DocumentDetailPage.tsx index 99064a7..ac2ade4 100644 --- a/frontend/src/pages/DocumentDetailPage.tsx +++ b/frontend/src/pages/DocumentDetailPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { api, downloadWithAuth } from '../api' +import ConfirmDialog from '../components/ConfirmDialog' import { useAuth } from '../context/AuthContext' import type { DocumentTemplate, FilledDocument } from '../types' @@ -13,6 +14,7 @@ export default function DocumentDetailPage() { const [previewHtml, setPreviewHtml] = useState(null) const [error, setError] = useState('') const [loading, setLoading] = useState(true) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) useEffect(() => { const load = async () => { @@ -61,7 +63,7 @@ export default function DocumentDetailPage() { } const handleDelete = async () => { - if (!document || !confirm(`Delete document "${document.name}"?`)) return + if (!document) return try { await api.deleteDocument(document.id) navigate('/documents') @@ -78,6 +80,16 @@ export default function DocumentDetailPage() { return (
+ void handleDelete()} + onCancel={() => setShowDeleteConfirm(false)} + /> +

{document.name}

@@ -105,7 +117,7 @@ export default function DocumentDetailPage() { )} {canManage && ( - )} diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 7e22351..69425b0 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { api } from '../api' +import ConfirmDialog from '../components/ConfirmDialog' import { useAuth } from '../context/AuthContext' import type { FilledDocument } from '../types' @@ -9,6 +10,7 @@ export default function DocumentsPage() { const [documents, setDocuments] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [deleteTarget, setDeleteTarget] = useState(null) const load = () => { api.listDocuments() @@ -21,11 +23,12 @@ export default function DocumentsPage() { load() }, []) - const handleDelete = async (doc: FilledDocument) => { - if (!confirm(`Delete document "${doc.name}"?`)) return + const handleDelete = async () => { + if (!deleteTarget) return try { - await api.deleteDocument(doc.id) - setDocuments((list) => list.filter((d) => d.id !== doc.id)) + await api.deleteDocument(deleteTarget.id) + setDocuments((list) => list.filter((d) => d.id !== deleteTarget.id)) + setDeleteTarget(null) } catch (err) { alert(err instanceof Error ? err.message : 'Delete failed') } @@ -35,6 +38,20 @@ export default function DocumentsPage() { return (
+ void handleDelete()} + onCancel={() => setDeleteTarget(null)} + /> +

Documents

@@ -79,7 +96,7 @@ export default function DocumentsPage() { {(isAdmin || d.owner_id === user?.id) && ( diff --git a/frontend/src/pages/LogsPage.tsx b/frontend/src/pages/LogsPage.tsx new file mode 100644 index 0000000..df0cb66 --- /dev/null +++ b/frontend/src/pages/LogsPage.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react' +import { Navigate } from 'react-router-dom' +import { api } from '../api' +import { useAuth } from '../context/AuthContext' +import type { AuditLog } from '../types' + +function formatAction(action: string): string { + return action.replace(/\./g, ' · ').replace(/_/g, ' ') +} + +function formatDetails(details: Record | null): string { + if (!details) return '—' + const text = JSON.stringify(details, null, 0) + return text.length > 120 ? `${text.slice(0, 120)}…` : text +} + +export default function LogsPage() { + const { user: currentUser } = useAuth() + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [actionFilter, setActionFilter] = useState('') + + const load = async (action?: string) => { + setLoading(true) + setError('') + try { + setLogs(await api.listLogs({ action: action || undefined })) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load logs') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) + + const handleFilter = (e: React.FormEvent) => { + e.preventDefault() + load(actionFilter.trim() || undefined) + } + + if (currentUser?.role !== 'admin') { + return + } + + return ( +
+
+

Activity Logs

+
+ +
+
+
+ + setActionFilter(e.target.value)} + placeholder="e.g. user.delete, template.create" + /> +
+ + +
+
+ + {error &&
{error}
} + + {loading ? ( +

Loading...

+ ) : ( +
+ + + + + + + + + + + + + {logs.length === 0 ? ( + + + + ) : ( + logs.map((log) => ( + + + + + + + + + )) + )} + +
TimeUserActionResourceDetailsIP
+ No log entries found +
+ {new Date(log.created_at).toLocaleString()} + {log.username || '—'} + {formatAction(log.action)} + + {log.resource_type + ? `${log.resource_type}${log.resource_id != null ? ` #${log.resource_id}` : ''}` + : '—'} + + + {formatDetails(log.details)} + + {log.ip_address || '—'}
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/TemplatesPage.tsx b/frontend/src/pages/TemplatesPage.tsx index 00e0b38..acddc82 100644 --- a/frontend/src/pages/TemplatesPage.tsx +++ b/frontend/src/pages/TemplatesPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { api, downloadWithAuth } from '../api' +import ConfirmDialog from '../components/ConfirmDialog' import { useAuth } from '../context/AuthContext' import type { TemplateListItem } from '../types' @@ -9,6 +10,7 @@ export default function TemplatesPage() { const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [deleteTarget, setDeleteTarget] = useState(null) const load = async () => { try { @@ -24,11 +26,12 @@ export default function TemplatesPage() { load() }, []) - const handleDelete = async (t: TemplateListItem) => { - if (!confirm(`Delete template "${t.name}"?`)) return + const handleDelete = async () => { + if (!deleteTarget) return try { - await api.deleteTemplate(t.id) - setTemplates((list) => list.filter((x) => x.id !== t.id)) + await api.deleteTemplate(deleteTarget.id) + setTemplates((list) => list.filter((x) => x.id !== deleteTarget.id)) + setDeleteTarget(null) } catch (err) { alert(err instanceof Error ? err.message : 'Delete failed') } @@ -65,6 +68,20 @@ export default function TemplatesPage() { return (
+ void handleDelete()} + onCancel={() => setDeleteTarget(null)} + /> +

Templates

@@ -142,7 +159,7 @@ export default function TemplatesPage() { diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 6803609..15c0407 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,9 +1,18 @@ import { useEffect, useState } from 'react' import { Navigate } from 'react-router-dom' import { api } from '../api' +import ConfirmDialog from '../components/ConfirmDialog' import { useAuth } from '../context/AuthContext' import type { User } from '../types' +type PendingConfirm = { + title: string + message: string + confirmLabel: string + danger?: boolean + onConfirm: () => Promise +} + export default function UsersPage() { const { user: currentUser } = useAuth() const [users, setUsers] = useState([]) @@ -11,6 +20,7 @@ export default function UsersPage() { const [error, setError] = useState('') const [showForm, setShowForm] = useState(false) const [editingUser, setEditingUser] = useState(null) + const [pendingConfirm, setPendingConfirm] = useState(null) const [form, setForm] = useState({ email: '', username: '', @@ -63,8 +73,7 @@ export default function UsersPage() { }) } - const handleEdit = async (e: React.FormEvent) => { - e.preventDefault() + const submitEdit = async () => { if (!editingUser) return setError('') try { @@ -82,22 +91,64 @@ export default function UsersPage() { } } - const handleToggleActive = async (u: User) => { - try { - await api.updateUser(u.id, { is_active: !u.is_active }) - await load() - } catch (err) { - alert(err instanceof Error ? err.message : 'Update failed') + const handleEdit = (e: React.FormEvent) => { + e.preventDefault() + if (!editingUser) return + + if (editForm.is_active !== editingUser.is_active) { + const activating = editForm.is_active + setPendingConfirm({ + title: activating ? 'Activate user' : 'Deactivate user', + message: activating + ? `Activate user "${editingUser.username}"? They will be able to sign in again.` + : `Deactivate user "${editingUser.username}"? They will no longer be able to sign in.`, + confirmLabel: activating ? 'Activate' : 'Deactivate', + danger: !activating, + onConfirm: submitEdit, + }) + return } + + void submitEdit() } - const handleDelete = async (u: User) => { - if (!confirm(`Delete user "${u.username}"?`)) return + const handleToggleActive = (u: User) => { + const activating = !u.is_active + setPendingConfirm({ + title: activating ? 'Activate user' : 'Deactivate user', + message: activating + ? `Activate user "${u.username}"? They will be able to sign in again.` + : `Deactivate user "${u.username}"? They will no longer be able to sign in.`, + confirmLabel: activating ? 'Activate' : 'Deactivate', + danger: !activating, + onConfirm: async () => { + await api.updateUser(u.id, { is_active: !u.is_active }) + await load() + }, + }) + } + + const handleDelete = (u: User) => { + setPendingConfirm({ + title: 'Delete user', + message: `Delete user "${u.username}"? This action cannot be undone.`, + confirmLabel: 'Delete', + danger: true, + onConfirm: async () => { + await api.deleteUser(u.id) + await load() + }, + }) + } + + const handleConfirm = async () => { + if (!pendingConfirm) return + const action = pendingConfirm.onConfirm + setPendingConfirm(null) try { - await api.deleteUser(u.id) - await load() + await action() } catch (err) { - alert(err instanceof Error ? err.message : 'Delete failed') + alert(err instanceof Error ? err.message : 'Action failed') } } @@ -107,6 +158,16 @@ export default function UsersPage() { return (
+ void handleConfirm()} + onCancel={() => setPendingConfirm(null)} + /> +

User Management