Feature - add logs for admin user
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
24
backend/app/models/audit_log.py
Normal file
24
backend/app/models/audit_log.py
Normal file
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
48
backend/app/routers/logs.py
Normal file
48
backend/app/routers/logs.py
Normal file
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
41
backend/app/services/audit.py
Normal file
41
backend/app/services/audit.py
Normal file
@@ -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)
|
||||
@@ -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() {
|
||||
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/documents/:id/edit" element={<ProtectedRoute><EditDocumentPage /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><UsersPage /></ProtectedRoute>} />
|
||||
<Route path="/logs" element={<ProtectedRoute><LogsPage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AuditLog,
|
||||
DocumentTemplate,
|
||||
FilledDocument,
|
||||
PreviewResponse,
|
||||
@@ -164,6 +165,15 @@ export const api = {
|
||||
return request<void>(`/users/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
async listLogs(params?: { limit?: number; offset?: number; action?: string }): Promise<AuditLog[]> {
|
||||
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<AuditLog[]>(`/logs${query ? `?${query}` : ''}`)
|
||||
},
|
||||
|
||||
async listTemplates(): Promise<TemplateListItem[]> {
|
||||
return request<TemplateListItem[]>('/templates')
|
||||
},
|
||||
|
||||
52
frontend/src/components/ConfirmDialog.tsx
Normal file
52
frontend/src/components/ConfirmDialog.tsx
Normal file
@@ -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 (
|
||||
<div className="dialog-overlay" onClick={onCancel}>
|
||||
<div
|
||||
className="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="confirm-dialog-title" style={{ marginTop: 0 }}>
|
||||
{title}
|
||||
</h2>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: 24 }}>{message}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,9 +21,14 @@ export default function Navbar() {
|
||||
Documents
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<Link to="/users" className={isActive('/users') ? 'active' : ''}>
|
||||
Users
|
||||
</Link>
|
||||
<>
|
||||
<Link to="/users" className={isActive('/users') ? 'active' : ''}>
|
||||
Users
|
||||
</Link>
|
||||
<Link to="/logs" className={isActive('/logs') ? 'active' : ''}>
|
||||
Logs
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{user && (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="container">
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
title="Delete document"
|
||||
message={`Delete document "${document.name}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>{document.name}</h1>
|
||||
@@ -105,7 +117,7 @@ export default function DocumentDetailPage() {
|
||||
</Link>
|
||||
)}
|
||||
{canManage && (
|
||||
<button className="btn btn-danger" onClick={handleDelete}>
|
||||
<button className="btn btn-danger" onClick={() => setShowDeleteConfirm(true)}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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<FilledDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<FilledDocument | null>(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 (
|
||||
<div className="container">
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
title="Delete document"
|
||||
message={
|
||||
deleteTarget
|
||||
? `Delete document "${deleteTarget.name}"? This action cannot be undone.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className="page-header">
|
||||
<h1>Documents</h1>
|
||||
<Link to="/templates" className="btn btn-primary">
|
||||
@@ -79,7 +96,7 @@ export default function DocumentsPage() {
|
||||
{(isAdmin || d.owner_id === user?.id) && (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDelete(d)}
|
||||
onClick={() => setDeleteTarget(d)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
135
frontend/src/pages/LogsPage.tsx
Normal file
135
frontend/src/pages/LogsPage.tsx
Normal file
@@ -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<string, unknown> | 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<AuditLog[]>([])
|
||||
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 <Navigate to="/templates" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<h1>Activity Logs</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleFilter} className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="form-group" style={{ marginBottom: 0, flex: '1 1 240px' }}>
|
||||
<label>Filter by action</label>
|
||||
<input
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
placeholder="e.g. user.delete, template.create"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setActionFilter('')
|
||||
load()
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Resource</th>
|
||||
<th>Details</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--muted)' }}>
|
||||
No log entries found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td>{log.username || '—'}</td>
|
||||
<td>
|
||||
<code className="log-action">{formatAction(log.action)}</code>
|
||||
</td>
|
||||
<td>
|
||||
{log.resource_type
|
||||
? `${log.resource_type}${log.resource_id != null ? ` #${log.resource_id}` : ''}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="style-hint" title={JSON.stringify(log.details, null, 2)}>
|
||||
{formatDetails(log.details)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{log.ip_address || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<TemplateListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<TemplateListItem | null>(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 (
|
||||
<div className="container">
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
title="Delete template"
|
||||
message={
|
||||
deleteTarget
|
||||
? `Delete template "${deleteTarget.name}"? This action cannot be undone.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className="page-header">
|
||||
<h1>Templates</h1>
|
||||
<Link to="/templates/new" className="btn btn-primary">
|
||||
@@ -142,7 +159,7 @@ export default function TemplatesPage() {
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDelete(t)}
|
||||
onClick={() => setDeleteTarget(t)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
@@ -11,6 +20,7 @@ export default function UsersPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm | null>(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 (
|
||||
<div className="container">
|
||||
<ConfirmDialog
|
||||
open={pendingConfirm !== null}
|
||||
title={pendingConfirm?.title ?? ''}
|
||||
message={pendingConfirm?.message ?? ''}
|
||||
confirmLabel={pendingConfirm?.confirmLabel}
|
||||
danger={pendingConfirm?.danger}
|
||||
onConfirm={() => void handleConfirm()}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
/>
|
||||
|
||||
<div className="page-header">
|
||||
<h1>User Management</h1>
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||
|
||||
@@ -78,3 +78,15 @@ export interface PreviewResponse {
|
||||
html: string
|
||||
field_data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number
|
||||
user_id: number | null
|
||||
username: string | null
|
||||
action: string
|
||||
resource_type: string | null
|
||||
resource_id: number | null
|
||||
details: Record<string, unknown> | null
|
||||
ip_address: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/Navbar.tsx","./src/components/TableRowEditor.tsx","./src/components/VariableField.tsx","./src/context/AuthContext.tsx","./src/pages/ActivatePage.tsx","./src/pages/CreateTemplatePage.tsx","./src/pages/DocumentDetailPage.tsx","./src/pages/DocumentsPage.tsx","./src/pages/EditDocumentPage.tsx","./src/pages/FillTemplatePage.tsx","./src/pages/ForgotPasswordPage.tsx","./src/pages/LoginPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/ResetPasswordPage.tsx","./src/pages/TemplateDetailPage.tsx","./src/pages/TemplatesPage.tsx","./src/pages/UsersPage.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/ConfirmDialog.tsx","./src/components/Navbar.tsx","./src/components/TableRowEditor.tsx","./src/components/VariableField.tsx","./src/context/AuthContext.tsx","./src/pages/ActivatePage.tsx","./src/pages/CreateTemplatePage.tsx","./src/pages/DocumentDetailPage.tsx","./src/pages/DocumentsPage.tsx","./src/pages/EditDocumentPage.tsx","./src/pages/FillTemplatePage.tsx","./src/pages/ForgotPasswordPage.tsx","./src/pages/LoginPage.tsx","./src/pages/LogsPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/ResetPasswordPage.tsx","./src/pages/TemplateDetailPage.tsx","./src/pages/TemplatesPage.tsx","./src/pages/UsersPage.tsx"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user