Feature - add logs for admin user

This commit is contained in:
2026-06-15 09:41:11 +03:00
parent bf1839607f
commit a1f523628e
23 changed files with 784 additions and 39 deletions

View File

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

View File

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

View File

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

View 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
)

View File

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

View File

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

View 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]

View File

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

View File

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

View File

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

View 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)

View File

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

View File

@@ -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')
},

View 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>
)
}

View File

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

View File

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

View File

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

View File

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

View 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>
)
}

View File

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

View File

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

View File

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

View File

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