commit bf1839607ff2280a49f35c4a857fc789641c3dd1 Author: Юрий Date: Mon Jun 15 09:18:58 2026 +0300 load project diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90be39c --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# PostgreSQL +POSTGRES_USER=doceditor +POSTGRES_PASSWORD=doceditor +POSTGRES_DB=doceditor + +# Application +SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32 +ADMIN_EMAIL=admin@example.com +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# URLs (used in emails and CORS) +FRONTEND_URL=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Ports exposed on host +BACKEND_PORT=8000 +FRONTEND_PORT=5173 + +# SMTP (optional — codes logged to backend console if empty) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@example.com +SMTP_USE_TLS=true + +ACTIVATION_CODE_EXPIRE_HOURS=24 +PASSWORD_RESET_CODE_EXPIRE_HOURS=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb26e2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +backend/.venv/ +backend/app.db +backend/uploads/ +backend/exports/ +frontend/node_modules/ +frontend/dist/ +__pycache__/ +*.pyc +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..0259378 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# Document Template Editor + +FastAPI + React application for managing Jinja2-annotated `.docx` document templates. + +## Features + +- **Authentication** with JWT (register/login) +- **User roles**: `user` and `admin` with admin user management panel +- **Initial admin**: seeded from `.env` on startup +- **Template upload**: load a `.docx` file with Jinja2 placeholders +- **Auto-detected form fields**: variables become text, textarea, number, date, table cell, or repeating table row fields +- **Style preservation**: font, alignment, table cell styles captured from the original document +- **Document preview**: render filled template as HTML preview +- **Export**: download as `.docx` or `.pdf` +- **Reusable templates**: fill the same template multiple times + +## Project Structure + +``` +backend/ # FastAPI API +frontend/ # React + Vite UI +samples/ # Sample .docx templates +``` + +## Quick Start (Docker) + +```bash +cp .env.example .env +docker compose up --build +``` + +| Service | URL | +|----------|-----| +| Frontend | http://localhost:5173 | +| Backend | http://localhost:8000 | +| API docs | http://localhost:8000/docs | + +Default admin: `admin` / `admin123` (configure in `.env`). + +Stop: `docker compose down` · Remove data: `docker compose down -v` + +## Quick Start (Local) + +Requires PostgreSQL running locally (or use Docker only for the database): + +```bash +docker compose up db -d +``` + +### Backend + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env +uvicorn app.main:app --reload --port 8000 +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Open http://localhost:5173 + +### Create Sample Template + +```bash +cd backend +source .venv/bin/activate +python ../samples/create_sample_template.py +``` + +## Jinja2 Syntax in .docx + +Use standard Jinja2 in your Word document: + +| Pattern | Purpose | +|---------|---------| +| `{{ client_name }}` | Simple text variable | +| `{{ description }}` | Auto-detected as textarea if name contains "description" | +| `{%tr for item in items %}` | Repeating table row (docxtpl syntax) | +| `{{ item.product }}` | Field inside table row loop | + +### Example Table Row + +In a Word table row, write: + +``` +{%tr for item in items %} +{{ item.name }} | {{ item.qty }} | {{ item.price }} +{%tr endfor %} +``` + +The parser detects `items` as a `table_row` field and `item.name`, `item.qty`, `item.price` as child `table_cell` fields with preserved table styles. + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/auth/register` | Register user | +| POST | `/api/auth/login` | Login (OAuth2 form) | +| GET | `/api/auth/me` | Current user | +| GET | `/api/users` | List users (admin) | +| POST | `/api/users` | Create user (admin) | +| PATCH | `/api/users/{id}` | Update user (admin) | +| DELETE | `/api/users/{id}` | Delete user (admin) | +| GET | `/api/templates` | List templates | +| POST | `/api/templates` | Upload template (.docx) | +| GET | `/api/templates/{id}` | Get template with variables | +| POST | `/api/documents` | Create filled document | +| POST | `/api/documents/preview` | Preview without saving | +| GET | `/api/documents/{id}/export/docx` | Export DOCX | +| GET | `/api/documents/{id}/export/pdf` | Export PDF | + +## PDF Export + +PDF export uses LibreOffice headless if available: + +```bash +# ALT Linux / Fedora +sudo apt install libreoffice # or your distro equivalent +``` + +Without LibreOffice, install `weasyprint` as fallback: + +```bash +pip install weasyprint +``` + +## Environment Variables + +Create `backend/.env`: + +```env +SECRET_KEY=your-secret-key-here +DATABASE_URL=postgresql+psycopg2://doceditor:doceditor@localhost:5432/doceditor +ADMIN_EMAIL=admin@example.com +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +FRONTEND_URL=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 +``` + +The admin user is created automatically on startup if it does not exist. + +## Workflow + +1. **Login** — use admin credentials from `.env`, or register as a regular user +2. **Upload Template** — select `.docx` with Jinja2 variables +3. **Fill Form** — dynamic form generated from detected variables +4. **Preview** — see rendered document with your data +5. **Save & Export** — document saved; export as DOCX or PDF +6. **Reuse** — use the same template again from Templates list + +## Tech Stack + +- **Backend**: FastAPI, SQLAlchemy, docxtpl, python-docx, JWT auth +- **Frontend**: React 18, Vite, TypeScript, React Router diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..5112a64 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +app.db +uploads +exports +.env +.git +.gitignore diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..3ada6dc --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,23 @@ +SECRET_KEY=change-me-in-production +DATABASE_URL=postgresql+psycopg2://doceditor:doceditor@localhost:5432/doceditor +CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173 + +# Initial admin user (created on startup if not exists) +ADMIN_EMAIL=admin@example.com +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# Frontend URL (used in activation/reset emails) +FRONTEND_URL=http://localhost:5173 + +# SMTP (leave SMTP_HOST empty to log emails to console in dev) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@example.com +SMTP_USE_TLS=true + +# Code expiration (hours) +ACTIVATION_CODE_EXPIRE_HOURS=24 +PASSWORD_RESET_CODE_EXPIRE_HOURS=1 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..80d2295 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +app.db +uploads/ +exports/ +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..29f33aa --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libreoffice-writer \ + libreoffice-calc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +RUN mkdir -p /app/uploads /app/exports + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..cb175a6 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,87 @@ +from datetime import datetime, timedelta +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.models.user import User, UserRole +from app.schemas import TokenData + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + ( + expires_delta or timedelta(minutes=settings.access_token_expire_minutes) + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + + +def get_user_by_username(db: Session, username: str) -> User | None: + return db.query(User).filter(User.username == username).first() + + +def get_user_by_email(db: Session, email: str) -> User | None: + return db.query(User).filter(User.email == email).first() + + +def authenticate_user(db: Session, username: str, password: str) -> User | None: + user = get_user_by_username(db, username) + if not user or not verify_password(password, user.hashed_password): + return None + return user + + +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + db: Annotated[Session, Depends(get_db)], +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + token, settings.secret_key, algorithms=[settings.algorithm] + ) + username: str | None = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + + user = get_user_by_username(db, username=token_data.username) + if user is None: + raise credentials_exception + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +async def get_current_admin( + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + if current_user.role != UserRole.admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required", + ) + return current_user diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c2bd4f4 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,33 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + secret_key: str = "change-me-in-production-use-openssl-rand-hex-32" + algorithm: str = "HS256" + access_token_expire_minutes: int = 60 * 24 + database_url: str = "postgresql+psycopg2://doceditor:doceditor@localhost:5432/doceditor" + upload_dir: str = "./uploads" + export_dir: str = "./exports" + admin_email: str = "admin@localhost" + admin_username: str = "admin" + admin_password: str = "admin123" + frontend_url: str = "http://localhost:5173" + cors_origins: str = "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173" + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_use_tls: bool = True + activation_code_expire_hours: int = 24 + password_reset_code_expire_hours: int = 1 + + class Config: + env_file = ".env" + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..e360126 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,34 @@ +from sqlalchemy import create_engine, event +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.config import settings + +_is_sqlite = settings.database_url.startswith("sqlite") + +engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False} if _is_sqlite else {}, + pool_pre_ping=True, +) + +if _is_sqlite: + + @event.listens_for(engine, "connect") + def set_sqlite_pragma(dbapi_connection, _connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c29c2ad --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,44 @@ +import os + +from fastapi import FastAPI +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.verification_token import VerificationToken # noqa: F401 +from app.routers import auth, documents, templates, users +from app.seed import seed_admin_user + +Base.metadata.create_all(bind=engine) +run_migrations() + +os.makedirs(settings.upload_dir, exist_ok=True) +os.makedirs(settings.export_dir, exist_ok=True) + +with SessionLocal() as db: + seed_admin_user(db) + +app = FastAPI( + title="Document Template Editor", + description="Upload Jinja2-annotated .docx templates, fill variables, preview and export", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(templates.router) +app.include_router(documents.router) + + +@app.get("/api/health") +def health(): + return {"status": "ok"} diff --git a/backend/app/migrations.py b/backend/app/migrations.py new file mode 100644 index 0000000..df501e9 --- /dev/null +++ b/backend/app/migrations.py @@ -0,0 +1,62 @@ +from sqlalchemy import inspect, text + +from app.database import engine + + +def run_migrations() -> None: + """Apply lightweight schema migrations for existing databases.""" + inspector = inspect(engine) + tables = inspector.get_table_names() + dialect = engine.dialect.name + + if "document_templates" in tables: + columns = {col["name"] for col in inspector.get_columns("document_templates")} + if "is_public" not in columns: + default = "0" if dialect == "sqlite" else "false" + with engine.begin() as conn: + conn.execute( + text( + f"ALTER TABLE document_templates " + f"ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT {default}" + ) + ) + + if "verification_tokens" not in tables: + with engine.begin() as conn: + if dialect == "postgresql": + conn.execute( + text( + """ + CREATE TABLE verification_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code VARCHAR(10) NOT NULL, + token_type VARCHAR(20) NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL + ) + """ + ) + ) + else: + conn.execute( + text( + """ + CREATE TABLE verification_tokens ( + id INTEGER NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL, + code VARCHAR(10) NOT NULL, + token_type VARCHAR(20) NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE + ) + """ + ) + ) + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_verification_tokens_code " + "ON verification_tokens (code)" + ) + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..9d80e28 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,14 @@ +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 + +__all__ = [ + "User", + "UserRole", + "DocumentTemplate", + "TemplateVariable", + "FilledDocument", + "VerificationToken", + "TokenType", +] diff --git a/backend/app/models/document.py b/backend/app/models/document.py new file mode 100644 index 0000000..05e12c0 --- /dev/null +++ b/backend/app/models/document.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class FilledDocument(Base): + __tablename__ = "filled_documents" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + template_id: Mapped[int] = mapped_column( + ForeignKey("document_templates.id", ondelete="CASCADE") + ) + owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + name: Mapped[str] = mapped_column(String(255)) + field_data: Mapped[str] = mapped_column(Text) + rendered_docx_path: Mapped[str | None] = mapped_column(String(512), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + template = relationship("DocumentTemplate", back_populates="documents") + owner = relationship("User", back_populates="documents") diff --git a/backend/app/models/template.py b/backend/app/models/template.py new file mode 100644 index 0000000..a10882e --- /dev/null +++ b/backend/app/models/template.py @@ -0,0 +1,54 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class DocumentTemplate(Base): + __tablename__ = "document_templates" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + original_filename: Mapped[str] = mapped_column(String(255)) + file_path: Mapped[str] = mapped_column(String(512)) + owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + is_public: Mapped[bool] = mapped_column(default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + owner = relationship("User", back_populates="templates") + variables = relationship( + "TemplateVariable", + back_populates="template", + cascade="all, delete-orphan", + order_by="TemplateVariable.order", + ) + documents = relationship( + "FilledDocument", + back_populates="template", + cascade="all, delete-orphan", + ) + + +class TemplateVariable(Base): + __tablename__ = "template_variables" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + template_id: Mapped[int] = mapped_column( + ForeignKey("document_templates.id", ondelete="CASCADE") + ) + name: Mapped[str] = mapped_column(String(255)) + label: Mapped[str] = mapped_column(String(255)) + field_type: Mapped[str] = mapped_column(String(50)) + default_value: Mapped[str | None] = mapped_column(Text, nullable=True) + is_required: Mapped[bool] = mapped_column(default=True) + order: Mapped[int] = mapped_column(Integer, default=0) + parent_variable: Mapped[str | None] = mapped_column(String(255), nullable=True) + style_params: Mapped[str | None] = mapped_column(Text, nullable=True) + + template = relationship("DocumentTemplate", back_populates="variables") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..47aa134 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,40 @@ +import enum +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class UserRole(str, enum.Enum): + user = "user" + admin = "admin" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + username: Mapped[str] = mapped_column(String(100), unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String(255)) + role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.user) + is_active: Mapped[bool] = mapped_column(default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + templates = relationship( + "DocumentTemplate", + back_populates="owner", + cascade="all, delete-orphan", + ) + documents = relationship( + "FilledDocument", + back_populates="owner", + cascade="all, delete-orphan", + ) + verification_tokens = relationship( + "VerificationToken", + back_populates="user", + cascade="all, delete-orphan", + ) diff --git a/backend/app/models/verification_token.py b/backend/app/models/verification_token.py new file mode 100644 index 0000000..c8e398d --- /dev/null +++ b/backend/app/models/verification_token.py @@ -0,0 +1,25 @@ +import enum +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class TokenType(str, enum.Enum): + activation = "activation" + password_reset = "password_reset" + + +class VerificationToken(Base): + __tablename__ = "verification_tokens" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + code: Mapped[str] = mapped_column(String(10), index=True) + token_type: Mapped[TokenType] = mapped_column(Enum(TokenType)) + expires_at: Mapped[datetime] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="verification_tokens") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..2b1ad34 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,160 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.auth import ( + authenticate_user, + create_access_token, + get_current_user, + get_password_hash, + get_user_by_email, + get_user_by_username, +) +from app.database import get_db +from app.models.user import User, UserRole +from app.models.verification_token import TokenType +from app.schemas import ( + ActivateAccountRequest, + ForgotPasswordRequest, + MessageResponse, + RegisterResponse, + ResetPasswordRequest, + Token, + UserCreate, + UserResponse, +) +from app.services.email_service import send_activation_email, send_password_reset_email +from app.services.verification import create_verification_token, verify_code + +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)]): + if user_data.password != user_data.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + + if get_user_by_email(db, user_data.email): + raise HTTPException(status_code=400, detail="Email already registered") + if get_user_by_username(db, user_data.username): + raise HTTPException(status_code=400, detail="Username already taken") + + user = User( + email=user_data.email, + username=user_data.username, + hashed_password=get_password_hash(user_data.password), + role=UserRole.user, + is_active=False, + ) + db.add(user) + db.flush() + + token = create_verification_token(db, user, TokenType.activation) + db.commit() + + send_activation_email(user.email, user.username, token.code) + + return RegisterResponse( + message="Registration successful. Check your email for the activation code.", + email=user.email, + ) + + +@router.post("/activate", response_model=MessageResponse) +def activate_account( + data: ActivateAccountRequest, + db: Annotated[Session, Depends(get_db)], +): + user = verify_code(db, data.email, data.code, TokenType.activation) + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired activation code") + + user.is_active = True + db.commit() + + return MessageResponse(message="Account activated. You can now log in.") + + +@router.post("/resend-activation", response_model=MessageResponse) +def resend_activation( + data: ForgotPasswordRequest, + db: Annotated[Session, Depends(get_db)], +): + user = get_user_by_email(db, data.email) + if not user: + return MessageResponse( + message="If the email exists, a new activation code has been sent." + ) + + if user.is_active: + raise HTTPException(status_code=400, detail="Account is already activated") + + token = create_verification_token(db, user, TokenType.activation) + db.commit() + send_activation_email(user.email, user.username, token.code) + + return MessageResponse( + message="If the email exists, a new activation code has been sent." + ) + + +@router.post("/forgot-password", response_model=MessageResponse) +def forgot_password( + data: ForgotPasswordRequest, + 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) + db.commit() + send_password_reset_email(user.email, user.username, token.code) + + return MessageResponse( + message="If the email exists, a password reset code has been sent." + ) + + +@router.post("/reset-password", response_model=MessageResponse) +def reset_password( + data: ResetPasswordRequest, + db: Annotated[Session, Depends(get_db)], +): + if data.password != data.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + + user = verify_code(db, data.email, data.code, TokenType.password_reset) + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired reset code") + + user.hashed_password = get_password_hash(data.password) + db.commit() + + return MessageResponse(message="Password updated. You can now log in.") + + +@router.post("/login", response_model=Token) +def login( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db: Annotated[Session, Depends(get_db)], +): + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account not activated. Check your email for the activation code.", + ) + token = create_access_token(data={"sub": user.username}) + return Token(access_token=token) + + +@router.get("/me", response_model=UserResponse) +def get_me(current_user: Annotated[User, Depends(get_current_user)]): + return current_user diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py new file mode 100644 index 0000000..8542034 --- /dev/null +++ b/backend/app/routers/documents.py @@ -0,0 +1,243 @@ +import json +import os +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session, joinedload + +from app.auth import get_current_user +from app.config import settings +from app.database import get_db +from app.models.document import FilledDocument +from app.models.template import DocumentTemplate +from app.models.user import User +from app.schemas import ( + FilledDocumentCreate, + FilledDocumentResponse, + FilledDocumentUpdate, + PreviewResponse, +) +from app.services.access import accessible_document, accessible_template, is_admin +from app.services.docx_renderer import docx_to_html, export_to_pdf, render_docx + +router = APIRouter(prefix="/api/documents", tags=["documents"]) + + +def _doc_to_response(doc: FilledDocument) -> FilledDocumentResponse: + field_data = json.loads(doc.field_data) + return FilledDocumentResponse( + id=doc.id, + template_id=doc.template_id, + owner_id=doc.owner_id, + owner_username=doc.owner.username if doc.owner else None, + name=doc.name, + field_data=field_data, + created_at=doc.created_at, + ) + + +def _get_template_for_document( + db: Session, user: User, template_id: int +) -> DocumentTemplate: + template = accessible_template(db, user, template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + return template + + +def _rerender_document(doc: FilledDocument, template: DocumentTemplate) -> None: + field_data = json.loads(doc.field_data) + output_path = doc.rendered_docx_path or os.path.join( + settings.export_dir, f"{uuid.uuid4().hex}_rendered.docx" + ) + os.makedirs(settings.export_dir, exist_ok=True) + render_docx(template.file_path, field_data, output_path) + doc.rendered_docx_path = output_path + + +@router.get("", response_model=list[FilledDocumentResponse]) +def list_documents( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + query = db.query(FilledDocument).options(joinedload(FilledDocument.owner)) + if not is_admin(current_user): + query = query.filter(FilledDocument.owner_id == current_user.id) + docs = query.order_by(FilledDocument.created_at.desc()).all() + return [_doc_to_response(d) for d in docs] + + +@router.get("/{document_id}", response_model=FilledDocumentResponse) +def get_document( + document_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + doc = ( + db.query(FilledDocument) + .options(joinedload(FilledDocument.owner)) + .filter(FilledDocument.id == document_id) + .first() + ) + if not doc or not accessible_document(db, current_user, document_id): + raise HTTPException(status_code=404, detail="Document not found") + return _doc_to_response(doc) + + +@router.patch("/{document_id}", response_model=FilledDocumentResponse) +def update_document( + document_id: int, + update: FilledDocumentUpdate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + doc = ( + db.query(FilledDocument) + .options(joinedload(FilledDocument.owner)) + .filter(FilledDocument.id == document_id) + .first() + ) + if not doc or not accessible_document(db, current_user, document_id): + raise HTTPException(status_code=404, detail="Document not found") + + if update.name is not None: + doc.name = update.name + if update.field_data is not None: + doc.field_data = json.dumps(update.field_data) + + template = db.query(DocumentTemplate).filter( + DocumentTemplate.id == doc.template_id + ).first() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + _rerender_document(doc, template) + db.commit() + db.refresh(doc) + return _doc_to_response(doc) + + +@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_document( + document_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + doc = db.query(FilledDocument).filter(FilledDocument.id == document_id).first() + if not doc or not accessible_document(db, current_user, document_id): + raise HTTPException(status_code=404, detail="Document not found") + + if doc.rendered_docx_path and os.path.exists(doc.rendered_docx_path): + os.remove(doc.rendered_docx_path) + + db.delete(doc) + db.commit() + + +@router.post("", response_model=FilledDocumentResponse, status_code=status.HTTP_201_CREATED) +def create_document( + data: FilledDocumentCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + template = _get_template_for_document(db, current_user, data.template_id) + + os.makedirs(settings.export_dir, exist_ok=True) + output_name = f"{uuid.uuid4().hex}_rendered.docx" + output_path = os.path.join(settings.export_dir, output_name) + + render_docx(template.file_path, data.field_data, output_path) + + doc = FilledDocument( + template_id=template.id, + owner_id=current_user.id, + name=data.name, + field_data=json.dumps(data.field_data), + rendered_docx_path=output_path, + ) + db.add(doc) + db.commit() + db.refresh(doc) + return _doc_to_response(doc) + + +@router.post("/preview", response_model=PreviewResponse) +def preview_document( + data: FilledDocumentCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + template = _get_template_for_document(db, current_user, data.template_id) + + os.makedirs(settings.export_dir, exist_ok=True) + temp_path = os.path.join(settings.export_dir, f"preview_{uuid.uuid4().hex}.docx") + + try: + render_docx(template.file_path, data.field_data, temp_path) + html = docx_to_html(temp_path) + return PreviewResponse(html=html, field_data=data.field_data) + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + +@router.get("/{document_id}/export/docx") +def export_docx( + document_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + doc = db.query(FilledDocument).filter(FilledDocument.id == document_id).first() + if not doc or not accessible_document(db, current_user, document_id): + raise HTTPException(status_code=404, detail="Document not found") + + if not doc.rendered_docx_path or not os.path.exists(doc.rendered_docx_path): + template = db.query(DocumentTemplate).filter( + DocumentTemplate.id == doc.template_id + ).first() + if template: + _rerender_document(doc, template) + db.commit() + + if not doc.rendered_docx_path: + raise HTTPException(status_code=404, detail="Rendered file not found") + + return FileResponse( + doc.rendered_docx_path, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + filename=f"{doc.name}.docx", + ) + + +@router.get("/{document_id}/export/pdf") +def export_pdf( + document_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + doc = db.query(FilledDocument).filter(FilledDocument.id == document_id).first() + if not doc or not accessible_document(db, current_user, document_id): + raise HTTPException(status_code=404, detail="Document not found") + + if not doc.rendered_docx_path or not os.path.exists(doc.rendered_docx_path): + template = db.query(DocumentTemplate).filter( + DocumentTemplate.id == doc.template_id + ).first() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + _rerender_document(doc, template) + db.commit() + + docx_path = doc.rendered_docx_path + if not docx_path: + raise HTTPException(status_code=404, detail="Rendered file not found") + + pdf_path = docx_path.replace(".docx", ".pdf") + try: + export_to_pdf(docx_path, pdf_path) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + + return FileResponse(pdf_path, media_type="application/pdf", filename=f"{doc.name}.pdf") diff --git a/backend/app/routers/templates.py b/backend/app/routers/templates.py new file mode 100644 index 0000000..94b14ec --- /dev/null +++ b/backend/app/routers/templates.py @@ -0,0 +1,274 @@ +import json +import os +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session, joinedload + +from app.auth import get_current_user +from app.config import settings +from app.database import get_db +from app.models.template import DocumentTemplate, TemplateVariable +from app.models.user import User +from app.schemas import ( + DocumentTemplateListItem, + DocumentTemplateResponse, + TemplateUpdate, + TemplateVariableResponse, + TemplateVariableUpdate, +) +from app.services.access import ( + accessible_template, + accessible_templates_query, + can_manage_template, + is_admin, + manageable_template, +) +from app.services.docx_parser import parse_docx_template + +router = APIRouter(prefix="/api/templates", tags=["templates"]) + + +def _owner_username(template: DocumentTemplate) -> str | None: + return template.owner.username if template.owner else None + + +def _template_to_response(template: DocumentTemplate, current_user: User) -> DocumentTemplateResponse: + variables = [] + for var in template.variables: + style = None + if var.style_params: + try: + style = json.loads(var.style_params) + except json.JSONDecodeError: + style = None + variables.append( + TemplateVariableResponse( + id=var.id, + name=var.name, + label=var.label, + field_type=var.field_type, + default_value=var.default_value, + is_required=var.is_required, + order=var.order, + parent_variable=var.parent_variable, + style_params=style, + ) + ) + return DocumentTemplateResponse( + id=template.id, + name=template.name, + description=template.description, + original_filename=template.original_filename, + owner_id=template.owner_id, + owner_username=_owner_username(template), + is_public=template.is_public, + is_owner=template.owner_id == current_user.id, + created_at=template.created_at, + updated_at=template.updated_at, + variables=variables, + ) + + +def _template_to_list_item(template: DocumentTemplate, current_user: User) -> DocumentTemplateListItem: + return DocumentTemplateListItem( + id=template.id, + name=template.name, + description=template.description, + original_filename=template.original_filename, + owner_id=template.owner_id, + owner_username=_owner_username(template), + is_public=template.is_public, + is_owner=template.owner_id == current_user.id, + created_at=template.created_at, + variable_count=len(template.variables), + ) + + +@router.get("", response_model=list[DocumentTemplateListItem]) +def list_templates( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + if is_admin(current_user): + templates = ( + db.query(DocumentTemplate) + .options(joinedload(DocumentTemplate.owner)) + .order_by(DocumentTemplate.created_at.desc()) + .all() + ) + else: + templates = ( + accessible_templates_query(db, current_user) + .options(joinedload(DocumentTemplate.owner)) + .order_by(DocumentTemplate.created_at.desc()) + .all() + ) + return [_template_to_list_item(t, current_user) for t in templates] + + +@router.get("/{template_id}", response_model=DocumentTemplateResponse) +def get_template( + template_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + template = accessible_template(db, current_user, template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + return _template_to_response(template, current_user) + + +@router.get("/{template_id}/download") +def download_template_source( + template_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + template = accessible_template(db, current_user, template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + if not os.path.exists(template.file_path): + raise HTTPException(status_code=404, detail="Source file not found") + + return FileResponse( + template.file_path, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + filename=template.original_filename, + ) + + +@router.patch("/{template_id}", response_model=DocumentTemplateResponse) +def update_template( + template_id: int, + update: TemplateUpdate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + template = manageable_template(db, current_user, template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + for field, value in update.model_dump(exclude_unset=True).items(): + setattr(template, field, value) + + db.commit() + db.refresh(template) + return _template_to_response(template, current_user) + + +@router.post("", response_model=DocumentTemplateResponse, status_code=status.HTTP_201_CREATED) +async def create_template( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], + file: UploadFile = File(...), + name: str = Form(...), + description: str | None = Form(None), + is_public: bool = Form(False), +): + if not file.filename or not file.filename.endswith(".docx"): + raise HTTPException(status_code=400, detail="Only .docx files are supported") + + os.makedirs(settings.upload_dir, exist_ok=True) + unique_name = f"{uuid.uuid4().hex}_{file.filename}" + file_path = os.path.join(settings.upload_dir, unique_name) + + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + parsed_vars = parse_docx_template(file_path) + + template = DocumentTemplate( + name=name, + description=description, + original_filename=file.filename, + file_path=file_path, + owner_id=current_user.id, + is_public=is_public, + ) + db.add(template) + db.flush() + + for pv in parsed_vars: + db.add( + TemplateVariable( + template_id=template.id, + name=pv.name, + label=pv.label, + field_type=pv.field_type, + default_value=pv.default_value, + is_required=pv.is_required, + order=pv.order, + parent_variable=pv.parent_variable, + style_params=json.dumps(pv.style_params) if pv.style_params else None, + ) + ) + + db.commit() + db.refresh(template) + return _template_to_response(template, current_user) + + +@router.put("/{template_id}/variables/{variable_id}", response_model=TemplateVariableResponse) +def update_variable( + template_id: int, + variable_id: int, + update: TemplateVariableUpdate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + template = manageable_template(db, current_user, template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + variable = db.query(TemplateVariable).filter( + TemplateVariable.id == variable_id, + TemplateVariable.template_id == template_id, + ).first() + if not variable: + raise HTTPException(status_code=404, detail="Variable not found") + + for field, value in update.model_dump(exclude_unset=True).items(): + setattr(variable, field, value) + + db.commit() + db.refresh(variable) + + style = None + if variable.style_params: + try: + style = json.loads(variable.style_params) + except json.JSONDecodeError: + pass + + return TemplateVariableResponse( + id=variable.id, + name=variable.name, + label=variable.label, + field_type=variable.field_type, + default_value=variable.default_value, + is_required=variable.is_required, + order=variable.order, + parent_variable=variable.parent_variable, + style_params=style, + ) + + +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_template( + template_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +): + template = manageable_template(db, current_user, template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + if os.path.exists(template.file_path): + os.remove(template.file_path) + + db.delete(template) + db.commit() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..ab6e118 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,122 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, 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.user_cleanup import cleanup_user_files + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +def _count_admins(db: Session) -> int: + return db.query(User).filter(User.role == UserRole.admin, User.is_active == True).count() + + +@router.get("", response_model=list[UserResponse]) +def list_users( + db: Annotated[Session, Depends(get_db)], + _: Annotated[User, Depends(get_current_admin)], +): + return db.query(User).order_by(User.created_at).all() + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def create_user( + user_data: AdminUserCreate, + db: Annotated[Session, Depends(get_db)], + _: Annotated[User, Depends(get_current_admin)], +): + from app.auth import get_user_by_email, get_user_by_username + + if get_user_by_email(db, user_data.email): + raise HTTPException(status_code=400, detail="Email already registered") + if get_user_by_username(db, user_data.username): + raise HTTPException(status_code=400, detail="Username already taken") + + user = User( + email=user_data.email, + username=user_data.username, + hashed_password=get_password_hash(user_data.password), + role=user_data.role, + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.patch("/{user_id}", response_model=UserResponse) +def update_user( + user_id: int, + update: UserUpdate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_admin)], +): + from app.auth import get_user_by_email + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.id == current_user.id: + if update.role is not None and update.role != UserRole.admin: + raise HTTPException(status_code=400, detail="Cannot demote yourself") + if update.is_active is False: + raise HTTPException(status_code=400, detail="Cannot deactivate yourself") + + if update.role is not None and update.role != UserRole.admin and user.role == UserRole.admin: + if _count_admins(db) <= 1: + raise HTTPException(status_code=400, detail="Cannot demote the last admin") + + if update.is_active is False and user.role == UserRole.admin: + if _count_admins(db) <= 1: + raise HTTPException(status_code=400, detail="Cannot deactivate the last admin") + + 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") + user.email = update.email + + if update.username is not None and update.username != user.username: + from app.auth import get_user_by_username + + if get_user_by_username(db, update.username): + raise HTTPException(status_code=400, detail="Username already taken") + user.username = update.username + + if update.role is not None: + user.role = update.role + if update.is_active is not None: + user.is_active = update.is_active + if update.password: + user.hashed_password = get_password_hash(update.password) + + db.commit() + db.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_admin)], +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + + if user.role == UserRole.admin and _count_admins(db) <= 1: + raise HTTPException(status_code=400, detail="Cannot delete the last admin") + + cleanup_user_files(db, user) + db.delete(user) + db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..6555898 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,179 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, EmailStr, Field + +from app.models.user import UserRole + + +class UserCreate(BaseModel): + email: EmailStr + username: str = Field(min_length=3, max_length=100) + password: str = Field(min_length=6) + confirm_password: str = Field(min_length=6) + + +class RegisterResponse(BaseModel): + message: str + email: str + + +class ActivateAccountRequest(BaseModel): + email: EmailStr + code: str = Field(min_length=4, max_length=10) + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + email: EmailStr + code: str = Field(min_length=4, max_length=10) + password: str = Field(min_length=6) + confirm_password: str = Field(min_length=6) + + +class MessageResponse(BaseModel): + message: str + + +class AdminUserCreate(BaseModel): + email: EmailStr + username: str = Field(min_length=3, max_length=100) + password: str = Field(min_length=6) + role: UserRole = UserRole.user + + +class UserUpdate(BaseModel): + email: EmailStr | None = None + username: str | None = Field(default=None, min_length=3, max_length=100) + role: UserRole | None = None + is_active: bool | None = None + password: str | None = Field(default=None, min_length=6) + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserResponse(BaseModel): + id: int + email: str + username: str + role: UserRole + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + username: str | None = None + + +class StyleParams(BaseModel): + font_name: str | None = None + font_size: float | None = None + bold: bool = False + italic: bool = False + underline: bool = False + color: str | None = None + alignment: str | None = None + background_color: str | None = None + border_style: str | None = None + width: float | None = None + height: float | None = None + table_style: dict[str, Any] | None = None + + +class TemplateVariableResponse(BaseModel): + id: int + name: str + label: str + field_type: str + default_value: str | None = None + is_required: bool + order: int + parent_variable: str | None = None + style_params: dict[str, Any] | None = None + + model_config = {"from_attributes": True} + + +class TemplateVariableUpdate(BaseModel): + label: str | None = None + field_type: str | None = None + default_value: str | None = None + is_required: bool | None = None + + +class TemplateUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + is_public: bool | None = None + + +class DocumentTemplateResponse(BaseModel): + id: int + name: str + description: str | None + original_filename: str + owner_id: int + owner_username: str | None = None + is_public: bool + is_owner: bool = False + created_at: datetime + updated_at: datetime + variables: list[TemplateVariableResponse] = [] + + model_config = {"from_attributes": True} + + +class DocumentTemplateListItem(BaseModel): + id: int + name: str + description: str | None + original_filename: str + owner_id: int + owner_username: str | None = None + is_public: bool + is_owner: bool = False + created_at: datetime + variable_count: int = 0 + + model_config = {"from_attributes": True} + + +class FilledDocumentCreate(BaseModel): + template_id: int + name: str + field_data: dict[str, Any] + + +class FilledDocumentUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + field_data: dict[str, Any] | None = None + + +class FilledDocumentResponse(BaseModel): + id: int + template_id: int + owner_id: int + owner_username: str | None = None + name: str + field_data: dict[str, Any] + created_at: datetime + + model_config = {"from_attributes": True} + + +class PreviewResponse(BaseModel): + html: str + field_data: dict[str, Any] diff --git a/backend/app/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..e0bcf6a --- /dev/null +++ b/backend/app/seed.py @@ -0,0 +1,32 @@ +from sqlalchemy.orm import Session + +from app.auth import get_password_hash, get_user_by_email, get_user_by_username +from app.config import settings +from app.models.user import User, UserRole + + +def seed_admin_user(db: Session) -> None: + """Create initial admin from .env if no user with that username exists.""" + if not settings.admin_username or not settings.admin_password: + return + + existing = get_user_by_username(db, settings.admin_username) + if existing: + if existing.role != UserRole.admin: + existing.role = UserRole.admin + db.commit() + return + + email = settings.admin_email or f"{settings.admin_username}@localhost" + if get_user_by_email(db, email): + email = f"{settings.admin_username}.admin@localhost" + + user = User( + email=email, + username=settings.admin_username, + hashed_password=get_password_hash(settings.admin_password), + role=UserRole.admin, + is_active=True, + ) + db.add(user) + db.commit() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/access.py b/backend/app/services/access.py new file mode 100644 index 0000000..8ddddca --- /dev/null +++ b/backend/app/services/access.py @@ -0,0 +1,66 @@ +from sqlalchemy import or_ +from sqlalchemy.orm import Query, Session + +from app.models.document import FilledDocument +from app.models.template import DocumentTemplate +from app.models.user import User, UserRole + + +def is_admin(user: User) -> bool: + return user.role == UserRole.admin + + +def can_access_template(user: User, template: DocumentTemplate) -> bool: + return ( + is_admin(user) + or template.owner_id == user.id + or template.is_public + ) + + +def can_manage_template(user: User, template: DocumentTemplate) -> bool: + return is_admin(user) or template.owner_id == user.id + + +def can_access_document(user: User, document: FilledDocument) -> bool: + return is_admin(user) or document.owner_id == user.id + + +def accessible_templates_query(db: Session, user: User) -> Query: + return db.query(DocumentTemplate).filter( + or_( + DocumentTemplate.owner_id == user.id, + DocumentTemplate.is_public == True, + ) + ) + + +def accessible_template( + db: Session, user: User, template_id: int +) -> DocumentTemplate | None: + template = db.query(DocumentTemplate).filter( + DocumentTemplate.id == template_id + ).first() + if template and can_access_template(user, template): + return template + return None + + +def manageable_template( + db: Session, user: User, template_id: int +) -> DocumentTemplate | None: + template = db.query(DocumentTemplate).filter( + DocumentTemplate.id == template_id + ).first() + if template and can_manage_template(user, template): + return template + return None + + +def accessible_document( + db: Session, user: User, document_id: int +) -> FilledDocument | None: + doc = db.query(FilledDocument).filter(FilledDocument.id == document_id).first() + if doc and can_access_document(user, doc): + return doc + return None diff --git a/backend/app/services/docx_parser.py b/backend/app/services/docx_parser.py new file mode 100644 index 0000000..8609bd5 --- /dev/null +++ b/backend/app/services/docx_parser.py @@ -0,0 +1,377 @@ +import json +import re +from dataclasses import dataclass, field +from typing import Any + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.shared import Pt +from docx.table import Table +from docx.text.paragraph import Paragraph +from jinja2 import Environment, meta + + +JINJA_VAR_PATTERN = re.compile(r"\{\{[^{}]*?\}\}") +JINJA_BLOCK_PATTERN = re.compile(r"\{%[^{}]*?%\}") +TABLE_ROW_LOOP_PATTERN = re.compile( + r"\{%\s*tr\s+for\s+(\w+)\s+in\s+(\w+)\s*%\}", re.IGNORECASE +) + + +@dataclass +class ParsedVariable: + name: str + label: str + field_type: str + order: int + parent_variable: str | None = None + style_params: dict[str, Any] = field(default_factory=dict) + default_value: str | None = None + is_required: bool = True + + +def _extract_run_style(run) -> dict[str, Any]: + style: dict[str, Any] = {} + if run.font.name: + style["font_name"] = run.font.name + if run.font.size: + style["font_size"] = run.font.size.pt + if run.bold: + style["bold"] = True + if run.italic: + style["italic"] = True + if run.underline: + style["underline"] = True + if run.font.color and run.font.color.rgb: + style["color"] = str(run.font.color.rgb) + return style + + +def _alignment_to_str(alignment) -> str | None: + if alignment is None: + return None + mapping = { + WD_ALIGN_PARAGRAPH.LEFT: "left", + WD_ALIGN_PARAGRAPH.CENTER: "center", + WD_ALIGN_PARAGRAPH.RIGHT: "right", + WD_ALIGN_PARAGRAPH.JUSTIFY: "justify", + } + return mapping.get(alignment) + + +def _get_paragraph_alignment_str(paragraph: Paragraph) -> str | None: + """Read paragraph alignment, including Word values like 'start'/'end'.""" + try: + alignment = paragraph.alignment + if alignment is not None: + return _alignment_to_str(alignment) + except ValueError: + pass + + p_pr = paragraph._p.pPr + if p_pr is not None: + jc = p_pr.find(qn("w:jc")) + if jc is not None: + val = jc.get(qn("w:val")) + xml_map = { + "left": "left", + "right": "right", + "center": "center", + "both": "justify", + "justify": "justify", + "start": "left", + "end": "right", + "distribute": "justify", + } + return xml_map.get(val) + return None + + +def _extract_paragraph_style(paragraph: Paragraph) -> dict[str, Any]: + style: dict[str, Any] = {} + alignment = _get_paragraph_alignment_str(paragraph) + if alignment is not None: + style["alignment"] = alignment + if paragraph.runs: + run_style = _extract_run_style(paragraph.runs[0]) + style.update(run_style) + return style + + +def _extract_cell_style(cell) -> dict[str, Any]: + style: dict[str, Any] = {} + if cell.paragraphs: + style.update(_extract_paragraph_style(cell.paragraphs[0])) + if cell.width: + style["width"] = cell.width.pt if hasattr(cell.width, "pt") else None + + tc = cell._tc + tc_pr = tc.tcPr + if tc_pr is not None: + shd = tc_pr.find(qn("w:shd")) + if shd is not None and shd.get(qn("w:fill")): + style["background_color"] = shd.get(qn("w:fill")) + return style + + +def _extract_table_style(table: Table) -> dict[str, Any]: + style: dict[str, Any] = {"rows": len(table.rows), "cols": len(table.columns)} + if table.rows: + style["row_styles"] = [ + [_extract_cell_style(cell) for cell in row.cells] + for row in table.rows + ] + return style + + +def _get_text_from_element(element) -> str: + if isinstance(element, Paragraph): + return element.text + if isinstance(element, Table): + parts = [] + for row in element.rows: + for cell in row.cells: + for p in cell.paragraphs: + parts.append(p.text) + return "\n".join(parts) + return "" + + +DOTTED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\.(\w+)\s*\}\}") + + +def _find_dotted_variables_in_text( + text: str, table_loops: dict[str, str] +) -> list[tuple[str, str, str]]: + """Return list of (field_name, parent_list_var, loop_item_var).""" + results: list[tuple[str, str, str]] = [] + for match in DOTTED_VAR_PATTERN.finditer(text): + item_var, field_name = match.groups() + parent = table_loops.get(item_var) + if parent: + results.append((field_name, parent, item_var)) + return results + + +MALFORMED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}?") + + +def _find_jinja_variables_in_text(text: str) -> list[str]: + names: set[str] = set() + env = Environment() + try: + ast = env.parse(text) + names.update(meta.find_undeclared_variables(ast)) + except Exception: + pass + names.update(re.findall(r"\{\{\s*(\w+)\s*\}\}", text)) + names.update(MALFORMED_VAR_PATTERN.findall(text)) + return sorted(names) + + +def _detect_field_type(var_name: str, text: str, in_table: bool, is_loop_item: bool) -> str: + if is_loop_item: + return "table_cell" + if in_table: + return "table_cell" + lower_text = text.lower() + if any(kw in lower_text for kw in ["description", "comment", "notes", "textarea"]): + return "textarea" + if any(kw in var_name.lower() for kw in ["date", "дата"]): + return "date" + if any(kw in var_name.lower() for kw in ["amount", "price", "sum", "qty", "count", "number"]): + return "number" + return "text" + + +def _humanize(name: str) -> str: + return name.replace("_", " ").replace("-", " ").title() + + +def parse_docx_template(file_path: str) -> list[ParsedVariable]: + doc = Document(file_path) + variables: list[ParsedVariable] = [] + seen: set[str] = set() + order = 0 + + table_loops: dict[str, str] = {} + full_text_parts: list[str] = [] + + for element in doc.element.body: + tag = element.tag.split("}")[-1] + if tag == "p": + para = Paragraph(element, doc) + full_text_parts.append(para.text) + text = para.text + for match in TABLE_ROW_LOOP_PATTERN.finditer(text): + item_var, list_var = match.groups() + table_loops[item_var] = list_var + elif tag == "tbl": + table = Table(element, doc) + for row in table.rows: + for cell in row.cells: + for p in cell.paragraphs: + full_text_parts.append(p.text) + text = p.text + for match in TABLE_ROW_LOOP_PATTERN.finditer(text): + item_var, list_var = match.groups() + table_loops[item_var] = list_var + + full_text = "\n".join(full_text_parts) + + dotted_fields: dict[str, tuple[str, str]] = {} + for text_part in full_text_parts: + for field_name, parent, item_var in _find_dotted_variables_in_text( + text_part, table_loops + ): + key = f"{parent}.{field_name}" + if key not in dotted_fields: + dotted_fields[key] = (field_name, parent) + + for list_var in sorted(set(table_loops.values())): + if list_var not in seen: + seen.add(list_var) + variables.append( + ParsedVariable( + name=list_var, + label=_humanize(list_var), + field_type="table_row", + order=order, + style_params={"is_repeating_table": True}, + ) + ) + order += 1 + + for key, (field_name, parent) in sorted(dotted_fields.items()): + if key not in seen: + seen.add(key) + table_style = _find_table_style_for_variable(doc, field_name) + field_type = _detect_field_type(field_name, field_name, True, True) + variables.append( + ParsedVariable( + name=field_name, + label=_humanize(field_name), + field_type=field_type, + order=order, + parent_variable=parent, + style_params=table_style, + ) + ) + order += 1 + + for item_var in table_loops: + seen.add(item_var) + + for element in doc.element.body: + tag = element.tag.split("}")[-1] + in_table = tag == "tbl" + + if tag == "p": + para = Paragraph(element, doc) + text = para.text + var_names = _find_jinja_variables_in_text(text) + para_style = _extract_paragraph_style(para) + for var_name in var_names: + if var_name in seen or var_name in table_loops: + continue + seen.add(var_name) + field_type = _detect_field_type(var_name, text, False, False) + variables.append( + ParsedVariable( + name=var_name, + label=_humanize(var_name), + field_type=field_type, + order=order, + style_params=para_style, + ) + ) + order += 1 + + elif tag == "tbl": + table = Table(element, doc) + table_style = _extract_table_style(table) + for row in table.rows: + for cell in row.cells: + for p in cell.paragraphs: + text = p.text + var_names = _find_jinja_variables_in_text(text) + cell_style = _extract_cell_style(cell) + cell_style["table_style"] = table_style + for var_name in var_names: + if var_name in seen or var_name in table_loops: + continue + if any( + f"{table_loops[iv]}.{fn}" in seen + for iv, fn in [ + (m.group(1), m.group(2)) + for m in DOTTED_VAR_PATTERN.finditer(text) + ] + if iv in table_loops + ): + continue + seen.add(var_name) + field_type = _detect_field_type(var_name, text, True, False) + variables.append( + ParsedVariable( + name=var_name, + label=_humanize(var_name), + field_type=field_type, + order=order, + style_params=cell_style, + ) + ) + order += 1 + + env = Environment() + try: + ast = env.parse(full_text) + all_vars = meta.find_undeclared_variables(ast) + for var_name in sorted(all_vars): + if var_name not in seen: + seen.add(var_name) + variables.append( + ParsedVariable( + name=var_name, + label=_humanize(var_name), + field_type="text", + order=order, + ) + ) + order += 1 + except Exception: + pass + + return variables + + +def _find_table_style_for_variable(doc: Document, var_name: str) -> dict[str, Any]: + for element in doc.element.body: + tag = element.tag.split("}")[-1] + if tag != "tbl": + continue + table = Table(element, doc) + for row in table.rows: + for cell in row.cells: + for p in cell.paragraphs: + if var_name in p.text or f".{var_name}" in p.text: + cell_style = _extract_cell_style(cell) + cell_style["table_style"] = _extract_table_style(table) + return cell_style + return {} + + +def variables_to_json(variables: list[ParsedVariable]) -> list[dict[str, Any]]: + return [ + { + "name": v.name, + "label": v.label, + "field_type": v.field_type, + "order": v.order, + "parent_variable": v.parent_variable, + "style_params": v.style_params, + "default_value": v.default_value, + "is_required": v.is_required, + } + for v in variables + ] diff --git a/backend/app/services/docx_renderer.py b/backend/app/services/docx_renderer.py new file mode 100644 index 0000000..bf50292 --- /dev/null +++ b/backend/app/services/docx_renderer.py @@ -0,0 +1,353 @@ +import json +import os +import re +import subprocess +from html import escape +from typing import Any + +from docx import Document +from docx.oxml.ns import qn +from docx.shared import Pt, RGBColor +from docx.table import Table, _Cell +from docxtpl import DocxTemplate + +from app.services.docx_parser import _get_paragraph_alignment_str + +TABLE_ROW_LOOP_PATTERN = re.compile( + r"\{%\s*tr\s+for\s+(\w+)\s+in\s+(\w+)\s*%\}", re.IGNORECASE +) +TABLE_ROW_END_PATTERN = re.compile(r"\{%\s*tr\s+endfor\s*%\}", re.IGNORECASE) +DOTTED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\.(\w+)\s*\}\}") +SIMPLE_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}") +TOLERANT_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}?") + + +def _prepare_context(field_data: dict[str, Any]) -> dict[str, Any]: + return dict(field_data) + + +def _clean_jinja_markers(text: str) -> str: + text = TABLE_ROW_LOOP_PATTERN.sub("", text) + text = TABLE_ROW_END_PATTERN.sub("", text) + return text.strip() + + +def _replace_variables_in_text(text: str, context: dict[str, Any]) -> str: + def replacer(match: re.Match) -> str: + name = match.group(1) + value = context.get(name, "") + return str(value) if value is not None else "" + + text = SIMPLE_VAR_PATTERN.sub(replacer, text) + if "{{" in text: + text = TOLERANT_VAR_PATTERN.sub(replacer, text) + return text + + +def _replace_simple_vars(text: str, context: dict[str, Any]) -> str: + return _replace_variables_in_text(text, context) + + +def _replace_vars_in_paragraph(paragraph, context: dict[str, Any]) -> None: + """Replace Jinja variables while preserving run formatting.""" + if "{{" not in paragraph.text: + return + + full_text = "".join(run.text for run in paragraph.runs) + new_text = _replace_variables_in_text(full_text, context) + if new_text == full_text: + return + + if paragraph.runs: + paragraph.runs[0].text = new_text + for run in paragraph.runs[1:]: + run.text = "" + else: + paragraph.add_run(new_text) + + +def _expand_table_rows(doc: Document, context: dict[str, Any]) -> None: + for table in doc.tables: + rows_to_process: list[tuple[int, str, str, list[str]]] = [] + + for row_idx, row in enumerate(table.rows): + row_text = " ".join( + p.text for cell in row.cells for p in cell.paragraphs + ) + loop_match = TABLE_ROW_LOOP_PATTERN.search(row_text) + if not loop_match: + continue + + item_var, list_var = loop_match.groups() + field_names: list[str] = [] + for cell in row.cells: + for p in cell.paragraphs: + for m in DOTTED_VAR_PATTERN.finditer(p.text): + if m.group(1) == item_var: + field = m.group(2) + if field not in field_names: + field_names.append(field) + + rows_to_process.append((row_idx, item_var, list_var, field_names)) + + for row_idx, item_var, list_var, field_names in reversed(rows_to_process): + template_row = table.rows[row_idx] + items = context.get(list_var, []) + if not isinstance(items, list): + items = [] + + if not items: + for cell in template_row.cells: + for p in cell.paragraphs: + _replace_vars_in_paragraph(p, context) + cleaned = _clean_jinja_markers(p.text) + if cleaned != p.text and p.runs: + p.runs[0].text = cleaned + for run in p.runs[1:]: + run.text = "" + continue + + template_cells = [ + [_clean_jinja_markers(p.text) for p in cell.paragraphs] + for cell in template_row.cells + ] + + for item in items: + new_row = table.add_row() + for cell_idx, cell in enumerate(new_row.cells): + if cell_idx < len(template_cells): + template_text = ( + template_cells[cell_idx][0] + if template_cells[cell_idx] + else "" + ) + result = template_text + for field in field_names: + placeholder = f"{{{{ {item_var}.{field} }}}}" + value = "" + if isinstance(item, dict): + value = item.get(field, "") + result = result.replace(placeholder, str(value)) + result = DOTTED_VAR_PATTERN.sub( + lambda m, it=item: str(it.get(m.group(2), "")) + if isinstance(it, dict) and m.group(1) == item_var + else m.group(0), + result, + ) + if cell.paragraphs: + _replace_vars_in_paragraph(cell.paragraphs[0], context) + if cell.paragraphs[0].text != result and cell.paragraphs[0].runs: + cell.paragraphs[0].runs[0].text = result + for run in cell.paragraphs[0].runs[1:]: + run.text = "" + else: + cell.text = result + + tbl = table._tbl + tr = template_row._tr + tbl.remove(tr) + + +def _replace_paragraph_vars(doc: Document, context: dict[str, Any]) -> None: + for para in doc.paragraphs: + _replace_vars_in_paragraph(para, context) + + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + if "{{" in para.text and not DOTTED_VAR_PATTERN.search(para.text): + _replace_vars_in_paragraph(para, context) + + +def render_docx(template_path: str, field_data: dict[str, Any], output_path: str) -> str: + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + context = _prepare_context(field_data) + + try: + tpl = DocxTemplate(template_path) + tpl.render(context) + tpl.save(output_path) + return output_path + except Exception: + doc = Document(template_path) + _expand_table_rows(doc, context) + _replace_paragraph_vars(doc, context) + doc.save(output_path) + return output_path + + +def _apply_style_to_run(run, style: dict[str, Any]) -> None: + if style.get("font_name"): + run.font.name = style["font_name"] + if style.get("font_size"): + run.font.size = Pt(style["font_size"]) + if style.get("bold"): + run.bold = True + if style.get("italic"): + run.italic = True + if style.get("underline"): + run.underline = True + if style.get("color"): + try: + run.font.color.rgb = RGBColor.from_string(style["color"]) + except Exception: + pass + + +def _run_to_html(run) -> str: + text = escape(run.text) + if not text: + return "" + styles: list[str] = [] + if run.bold: + styles.append("font-weight:bold") + if run.italic: + styles.append("font-style:italic") + if run.underline: + styles.append("text-decoration:underline") + if run.font.size: + styles.append(f"font-size:{run.font.size.pt}pt") + if run.font.name: + styles.append(f"font-family:'{run.font.name}'") + if styles: + return f'{text}' + return text + + +def _paragraph_to_html(paragraph) -> str: + parts = [_run_to_html(run) for run in paragraph.runs] + return "".join(parts) if any(parts) else escape(paragraph.text) + + +def _get_cell_colspan(tc) -> int: + tc_pr = tc.tcPr + if tc_pr is None: + return 1 + grid_span = tc_pr.find(qn("w:gridSpan")) + if grid_span is not None: + return int(grid_span.get(qn("w:val"), 1)) + return 1 + + +def _get_vmerge_state(tc) -> str | None: + tc_pr = tc.tcPr + if tc_pr is None: + return None + v_merge = tc_pr.find(qn("w:vMerge")) + if v_merge is None: + return None + return v_merge.get(qn("w:val")) or "continue" + + +def _cell_to_html(cell: _Cell) -> str: + return "
".join(_paragraph_to_html(p) for p in cell.paragraphs) + + +def _table_to_html(table: Table) -> str: + """Render table using actual w:tc elements (handles merged cells).""" + rows_html: list[str] = [] + + for row in table.rows: + tr = row._tr + cells_html: list[str] = [] + + for tc in tr.tc_lst: + if _get_vmerge_state(tc) == "continue": + continue + + cell = _Cell(tc, table) + colspan = _get_cell_colspan(tc) + attrs = "" + if colspan > 1: + attrs += f' colspan="{colspan}"' + + cells_html.append(f"{_cell_to_html(cell)}") + + rows_html.append(f"{''.join(cells_html)}") + + return ( + '' + + "".join(rows_html) + + "
" + ) + + +def docx_to_html(file_path: str) -> str: + doc = Document(file_path) + html_parts = ['
'] + + for element in doc.element.body: + tag = element.tag.split("}")[-1] + if tag == "p": + from docx.text.paragraph import Paragraph + + para = Paragraph(element, doc) + align = "" + alignment = _get_paragraph_alignment_str(para) + if alignment is not None: + align = f' style="text-align:{alignment}"' + text = _paragraph_to_html(para) + html_parts.append(f"{text}

") + elif tag == "tbl": + table = Table(element, doc) + html_parts.append(_table_to_html(table)) + + html_parts.append("
") + return "\n".join(html_parts) + + +def export_to_pdf(docx_path: str, output_path: str) -> str: + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + + try: + result = subprocess.run( + [ + "libreoffice", + "--headless", + "--convert-to", + "pdf", + "--outdir", + os.path.dirname(output_path), + docx_path, + ], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode == 0: + base = os.path.splitext(os.path.basename(docx_path))[0] + generated = os.path.join(os.path.dirname(output_path), f"{base}.pdf") + if os.path.exists(generated) and generated != output_path: + os.rename(generated, output_path) + return output_path + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + return _fallback_pdf_export(docx_path, output_path) + + +def _fallback_pdf_export(docx_path: str, output_path: str) -> str: + html = docx_to_html(docx_path) + full_html = f""" + +{html}""" + + try: + import weasyprint + + weasyprint.HTML(string=full_html).write_pdf(output_path) + return output_path + except ImportError: + html_path = output_path.replace(".pdf", ".html") + with open(html_path, "w", encoding="utf-8") as f: + f.write(full_html) + raise RuntimeError( + "PDF export requires LibreOffice (libreoffice) or weasyprint. " + f"HTML preview saved to {html_path}" + ) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..65c8975 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,57 @@ +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from app.config import settings + +logger = logging.getLogger(__name__) + + +def _send_smtp(to_email: str, subject: str, body: str) -> None: + if not settings.smtp_host: + logger.warning( + "SMTP not configured. Email to %s — subject: %s\n%s", + to_email, + subject, + body, + ) + return + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = settings.smtp_from or settings.smtp_user + msg["To"] = to_email + msg.attach(MIMEText(body, "plain", "utf-8")) + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server: + if settings.smtp_use_tls: + server.starttls() + if settings.smtp_user and settings.smtp_password: + server.login(settings.smtp_user, settings.smtp_password) + server.sendmail(msg["From"], [to_email], msg.as_string()) + + +def send_activation_email(to_email: str, username: str, code: str) -> None: + activate_url = f"{settings.frontend_url.rstrip('/')}/activate" + body = ( + f"Hello {username},\n\n" + f"Thank you for registering at Document Template Editor.\n\n" + f"Your activation code: {code}\n\n" + f"Enter this code at: {activate_url}\n\n" + f"The code expires in {settings.activation_code_expire_hours} hours.\n" + ) + _send_smtp(to_email, "Activate your account", body) + + +def send_password_reset_email(to_email: str, username: str, code: str) -> None: + reset_url = f"{settings.frontend_url.rstrip('/')}/reset-password" + body = ( + f"Hello {username},\n\n" + f"You requested a password reset.\n\n" + f"Your reset code: {code}\n\n" + f"Enter this code at: {reset_url}\n\n" + f"The code expires in {settings.password_reset_code_expire_hours} hours.\n\n" + f"If you did not request this, ignore this email.\n" + ) + _send_smtp(to_email, "Password reset code", body) diff --git a/backend/app/services/user_cleanup.py b/backend/app/services/user_cleanup.py new file mode 100644 index 0000000..cd684ed --- /dev/null +++ b/backend/app/services/user_cleanup.py @@ -0,0 +1,36 @@ +import os + +from sqlalchemy.orm import Session + +from app.models.document import FilledDocument +from app.models.template import DocumentTemplate +from app.models.user import User + + +def cleanup_user_files(db: Session, user: User) -> None: + """Remove uploaded template and rendered document files for a user.""" + templates = ( + db.query(DocumentTemplate).filter(DocumentTemplate.owner_id == user.id).all() + ) + template_ids = [t.id for t in templates] + + for template in templates: + if template.file_path and os.path.exists(template.file_path): + os.remove(template.file_path) + + documents = db.query(FilledDocument).filter( + FilledDocument.owner_id == user.id + ).all() + if template_ids: + documents += ( + db.query(FilledDocument) + .filter(FilledDocument.template_id.in_(template_ids)) + .all() + ) + + seen_paths: set[str] = set() + for doc in documents: + if doc.rendered_docx_path and doc.rendered_docx_path not in seen_paths: + seen_paths.add(doc.rendered_docx_path) + if os.path.exists(doc.rendered_docx_path): + os.remove(doc.rendered_docx_path) diff --git a/backend/app/services/verification.py b/backend/app/services/verification.py new file mode 100644 index 0000000..6fe1466 --- /dev/null +++ b/backend/app/services/verification.py @@ -0,0 +1,67 @@ +import random +import string +from datetime import datetime, timedelta + +from sqlalchemy.orm import Session + +from app.config import settings +from app.models.user import User +from app.models.verification_token import TokenType, VerificationToken + + +def _generate_code(length: int = 6) -> str: + return "".join(random.choices(string.digits, k=length)) + + +def _invalidate_tokens(db: Session, user_id: int, token_type: TokenType) -> None: + db.query(VerificationToken).filter( + VerificationToken.user_id == user_id, + VerificationToken.token_type == token_type, + ).delete() + + +def create_verification_token( + db: Session, user: User, token_type: TokenType +) -> VerificationToken: + _invalidate_tokens(db, user.id, token_type) + + if token_type == TokenType.activation: + hours = settings.activation_code_expire_hours + else: + hours = settings.password_reset_code_expire_hours + + token = VerificationToken( + user_id=user.id, + code=_generate_code(), + token_type=token_type, + expires_at=datetime.utcnow() + timedelta(hours=hours), + ) + db.add(token) + db.flush() + return token + + +def verify_code( + db: Session, email: str, code: str, token_type: TokenType +) -> User | None: + from app.auth import get_user_by_email + + user = get_user_by_email(db, email) + if not user: + return None + + token = ( + db.query(VerificationToken) + .filter( + VerificationToken.user_id == user.id, + VerificationToken.token_type == token_type, + VerificationToken.code == code.strip(), + VerificationToken.expires_at > datetime.utcnow(), + ) + .first() + ) + if not token: + return None + + db.delete(token) + return user diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..710d10d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.20 +docxtpl==0.19.0 +python-docx==1.1.2 +jinja2==3.1.4 +aiofiles==24.1.0 +pydantic==2.10.3 +pydantic-settings==2.7.0 +email-validator==2.2.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dec1c6a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-doceditor} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-doceditor} + POSTGRES_DB: ${POSTGRES_DB:-doceditor} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-doceditor} -d ${POSTGRES_DB:-doceditor}", + ] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + backend: + build: + context: ./backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-doceditor}:${POSTGRES_PASSWORD:-doceditor}@db:5432/${POSTGRES_DB:-doceditor} + SECRET_KEY: ${SECRET_KEY:-change-me-in-production} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} + FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000} + UPLOAD_DIR: /app/uploads + EXPORT_DIR: /app/exports + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-noreply@example.com} + SMTP_USE_TLS: ${SMTP_USE_TLS:-true} + ACTIVATION_CODE_EXPIRE_HOURS: ${ACTIVATION_CODE_EXPIRE_HOURS:-24} + PASSWORD_RESET_CODE_EXPIRE_HOURS: ${PASSWORD_RESET_CODE_EXPIRE_HOURS:-1} + volumes: + - uploads_data:/app/uploads + - exports_data:/app/exports + ports: + - "${BACKEND_PORT:-8000}:8000" + + frontend: + build: + context: ./frontend + restart: unless-stopped + depends_on: + - backend + ports: + - "${FRONTEND_PORT:-5173}:80" + +volumes: + postgres_data: + uploads_data: + exports_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e02be5c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.gitignore +*.local diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5b08070 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4674ae5 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..455975f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Document Template Editor + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..46ea261 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,21 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 50M; + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..675e84c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1774 @@ +{ + "name": "document-template-editor-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "document-template-editor-frontend", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..805f48b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "document-template-editor-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f8e925d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,59 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import Navbar from './components/Navbar' +import { AuthProvider, useAuth } from './context/AuthContext' +import CreateTemplatePage from './pages/CreateTemplatePage' +import DocumentDetailPage from './pages/DocumentDetailPage' +import DocumentsPage from './pages/DocumentsPage' +import EditDocumentPage from './pages/EditDocumentPage' +import FillTemplatePage from './pages/FillTemplatePage' +import LoginPage from './pages/LoginPage' +import RegisterPage from './pages/RegisterPage' +import ActivatePage from './pages/ActivatePage' +import ForgotPasswordPage from './pages/ForgotPasswordPage' +import ResetPasswordPage from './pages/ResetPasswordPage' +import TemplateDetailPage from './pages/TemplateDetailPage' +import TemplatesPage from './pages/TemplatesPage' +import UsersPage from './pages/UsersPage' + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth() + if (loading) return

Loading...

+ if (!user) return + return <>{children} +} + +function AppRoutes() { + const { user } = useAuth() + + return ( + <> + {user && } + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export default function App() { + return ( + + + + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..3c5234a --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,276 @@ +import type { + DocumentTemplate, + FilledDocument, + PreviewResponse, + TemplateListItem, + User, +} from './types' + +const API_BASE = '/api' + +function getToken(): string | null { + return localStorage.getItem('token') +} + +async function request( + path: string, + options: RequestInit = {}, +): Promise { + const token = getToken() + const headers: Record = { + ...(options.headers as Record), + } + + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = headers['Content-Type'] || 'application/json' + } + + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Request failed' })) + const detail = error.detail + const message = + typeof detail === 'string' + ? detail + : Array.isArray(detail) + ? detail.map((d: { msg?: string }) => d.msg).filter(Boolean).join(', ') || 'Request failed' + : `HTTP ${response.status}` + throw new Error(message) + } + + if (response.status === 204) { + return undefined as T + } + + return response.json() +} + +export const api = { + async login(username: string, password: string): Promise<{ access_token: string }> { + const form = new FormData() + form.append('username', username) + form.append('password', password) + + const response = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + body: form, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Login failed' })) + const detail = error.detail + const message = + typeof detail === 'string' + ? detail + : Array.isArray(detail) + ? detail.map((d: { msg?: string }) => d.msg).filter(Boolean).join(', ') || 'Login failed' + : 'Login failed' + throw new Error(message) + } + + return response.json() + }, + + async register(data: { + email: string + username: string + password: string + confirm_password: string + }): Promise<{ message: string; email: string }> { + return request<{ message: string; email: string }>('/auth/register', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + + async activateAccount(email: string, code: string): Promise<{ message: string }> { + return request<{ message: string }>('/auth/activate', { + method: 'POST', + body: JSON.stringify({ email, code }), + }) + }, + + async resendActivation(email: string): Promise<{ message: string }> { + return request<{ message: string }>('/auth/resend-activation', { + method: 'POST', + body: JSON.stringify({ email }), + }) + }, + + async forgotPassword(email: string): Promise<{ message: string }> { + return request<{ message: string }>('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }) + }, + + async resetPassword(data: { + email: string + code: string + password: string + confirm_password: string + }): Promise<{ message: string }> { + return request<{ message: string }>('/auth/reset-password', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + + async getMe(): Promise { + return request('/auth/me') + }, + + async listUsers(): Promise { + return request('/users') + }, + + async createUser(data: { + email: string + username: string + password: string + role: 'user' | 'admin' + }): Promise { + return request('/users', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + + async updateUser( + id: number, + data: { + email?: string + username?: string + role?: 'user' | 'admin' + is_active?: boolean + password?: string + }, + ): Promise { + return request(`/users/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + }, + + async deleteUser(id: number): Promise { + return request(`/users/${id}`, { method: 'DELETE' }) + }, + + async listTemplates(): Promise { + return request('/templates') + }, + + async getTemplate(id: number): Promise { + return request(`/templates/${id}`) + }, + + async createTemplate( + file: File, + name: string, + description?: string, + isPublic?: boolean, + ): Promise { + const form = new FormData() + form.append('file', file) + form.append('name', name) + if (description) form.append('description', description) + form.append('is_public', String(isPublic ?? false)) + + return request('/templates', { + method: 'POST', + body: form, + }) + }, + + async deleteTemplate(id: number): Promise { + return request(`/templates/${id}`, { method: 'DELETE' }) + }, + + async updateTemplate( + id: number, + data: { name?: string; description?: string; is_public?: boolean }, + ): Promise { + return request(`/templates/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + }, + + getTemplateSourceUrl(templateId: number): string { + return `${API_BASE}/templates/${templateId}/download` + }, + + async listDocuments(): Promise { + return request('/documents') + }, + + async getDocument(id: number): Promise { + return request(`/documents/${id}`) + }, + + async createDocument(data: { + template_id: number + name: string + field_data: Record + }): Promise { + return request('/documents', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + + async updateDocument( + id: number, + data: { name?: string; field_data?: Record }, + ): Promise { + return request(`/documents/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + }, + + async deleteDocument(id: number): Promise { + return request(`/documents/${id}`, { method: 'DELETE' }) + }, + + async previewDocument(data: { + template_id: number + name: string + field_data: Record + }): Promise { + return request('/documents/preview', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + + getExportDocxUrl(documentId: number): string { + return `${API_BASE}/documents/${documentId}/export/docx` + }, + + getExportPdfUrl(documentId: number): string { + return `${API_BASE}/documents/${documentId}/export/pdf` + }, +} + +export async function downloadWithAuth(url: string, filename: string) { + const token = getToken() + const response = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!response.ok) throw new Error('Download failed') + const blob = await response.blob() + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + link.click() + URL.revokeObjectURL(link.href) +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..410fd11 --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,42 @@ +import { Link, useLocation } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export default function Navbar() { + const { user, logout } = useAuth() + const location = useLocation() + + const isActive = (path: string) => + location.pathname === path || location.pathname.startsWith(path + '/') + + return ( + + ) +} diff --git a/frontend/src/components/TableRowEditor.tsx b/frontend/src/components/TableRowEditor.tsx new file mode 100644 index 0000000..0320e0d --- /dev/null +++ b/frontend/src/components/TableRowEditor.tsx @@ -0,0 +1,80 @@ +import type { TemplateVariable } from '../types' +import VariableField from './VariableField' + +interface TableRowEditorProps { + tableVariable: TemplateVariable + childVariables: TemplateVariable[] + rows: Record[] + onChange: (rows: Record[]) => void +} + +export default function TableRowEditor({ + tableVariable, + childVariables, + rows, + onChange, +}: TableRowEditorProps) { + const addRow = () => { + const newRow: Record = {} + childVariables.forEach((v) => { + newRow[v.name] = v.default_value ?? '' + }) + onChange([...rows, newRow]) + } + + const removeRow = (index: number) => { + onChange(rows.filter((_, i) => i !== index)) + } + + const updateRowField = (rowIndex: number, field: string, value: unknown) => { + const updated = rows.map((row, i) => + i === rowIndex ? { ...row, [field]: value } : row, + ) + onChange(updated) + } + + const tableStyle = tableVariable.style_params?.table_style as + | { row_styles?: Record[][] } + | undefined + + return ( +
+ + {tableStyle && ( +
+ Table styles preserved from template ({tableStyle.row_styles?.length ?? 0} reference + rows) +
+ )} + + {rows.map((row, rowIndex) => ( +
+
+ Row {rowIndex + 1} + +
+
+ {childVariables.map((child) => ( + updateRowField(rowIndex, child.name, val)} + /> + ))} +
+
+ ))} + + +
+ ) +} diff --git a/frontend/src/components/VariableField.tsx b/frontend/src/components/VariableField.tsx new file mode 100644 index 0000000..51ba313 --- /dev/null +++ b/frontend/src/components/VariableField.tsx @@ -0,0 +1,71 @@ +import type { TemplateVariable } from '../types' + +interface VariableFieldProps { + variable: TemplateVariable + value: unknown + onChange: (value: unknown) => void +} + +export default function VariableField({ variable, value, onChange }: VariableFieldProps) { + const styleHint = variable.style_params + ? [ + variable.style_params.font_name && `Font: ${variable.style_params.font_name}`, + variable.style_params.font_size && `Size: ${variable.style_params.font_size}pt`, + variable.style_params.bold && 'Bold', + variable.style_params.alignment && `Align: ${variable.style_params.alignment}`, + ] + .filter(Boolean) + .join(' · ') + : null + + const renderInput = () => { + switch (variable.field_type) { + case 'textarea': + return ( +