diff --git a/.env.alpha.example b/.env.alpha.example index b828df4..edae4ee 100644 --- a/.env.alpha.example +++ b/.env.alpha.example @@ -1,17 +1,28 @@ APP_NAME=Lean 101 Clients API +APP_ENV=alpha CLIENT_NAME=Hunter Premium Produce CLIENT_EMAIL=operator@example.com -CLIENT_PASSWORD=changeme +CLIENT_PASSWORD=replace-with-strong-password CLIENT_TENANT_ID=hunter-premium-produce ADMIN_NAME=Lean 101 -ADMIN_EMAIL=admin@lean101.local -ADMIN_PASSWORD=lean101-admin -AUTH_SECRET=replace-with-a-long-random-secret -ORIGIN=https://clients.lean-101.com.au -PUBLIC_API_BASE_URL=https://clients.lean-101.com.au +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=replace-with-strong-password +AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret +ORIGIN=https://clients.example.com +PUBLIC_API_BASE_URL=https://clients.example.com INTERNAL_API_BASE_URL=http://backend:8000 -CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au +CORS_ALLOW_ORIGINS=https://clients.example.com +CORS_ALLOW_ORIGIN_REGEX= +TRUSTED_HOSTS=clients.example.com CLIENTS_APP_PORT=8081 +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_SAMESITE=lax +SESSION_COOKIE_DOMAIN= +SESSION_TTL_SECONDS=43200 +REQUEST_BODY_MAX_BYTES=1048576 +LOGIN_RATE_LIMIT_ATTEMPTS=8 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 +DOCS_ENABLED=false PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false DATABASE_URL=sqlite:////data/data_entry_app.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dfb20e3 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +APP_NAME=Lean 101 Clients API +APP_ENV=production +CLIENT_NAME=Hunter Premium Produce +CLIENT_EMAIL=operator@example.com +CLIENT_PASSWORD=replace-with-a-strong-client-password +CLIENT_TENANT_ID=hunter-premium-produce +ADMIN_NAME=Lean 101 +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=replace-with-a-strong-admin-password +AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret + +POSTGRES_USER=lean101_app +POSTGRES_PASSWORD=replace-with-a-long-random-database-password +POSTGRES_DB=lean101 + +ORIGIN=https://clients.example.com +PUBLIC_API_BASE_URL=https://clients.example.com +INTERNAL_API_BASE_URL=http://backend:8000 +CORS_ALLOW_ORIGINS=https://clients.example.com +CORS_ALLOW_ORIGIN_REGEX= +TRUSTED_HOSTS=clients.example.com,localhost,127.0.0.1 + +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_SAMESITE=lax +SESSION_COOKIE_DOMAIN= +SESSION_TTL_SECONDS=43200 +REQUEST_BODY_MAX_BYTES=1048576 +LOGIN_RATE_LIMIT_ATTEMPTS=8 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 +DOCS_ENABLED=false + +PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false +PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false diff --git a/.env.production.example b/.env.production.example index d47a5e0..8eada0e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,26 +1,37 @@ APP_NAME=Lean 101 Clients API +APP_ENV=production CLIENT_NAME=Hunter Premium Produce CLIENT_EMAIL=operator@example.com CLIENT_PASSWORD=replace-with-strong-password CLIENT_TENANT_ID=hunter-premium-produce ADMIN_NAME=Lean 101 -ADMIN_EMAIL=admin@lean101.local +ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=replace-with-strong-password -AUTH_SECRET=replace-with-a-long-random-secret +AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret # Postgres credentials. The compose file builds DATABASE_URL from these # so you do not need to set DATABASE_URL explicitly. Override DATABASE_URL # only if you want to point at a managed Postgres outside the compose stack. -POSTGRES_USER=lean101 +POSTGRES_USER=lean101_app POSTGRES_PASSWORD=replace-with-a-long-random-password POSTGRES_DB=lean101 # DATABASE_URL=postgresql+psycopg://USER:PASS@HOST:5432/DBNAME -ORIGIN=https://clients.lean-101.com.au -PUBLIC_API_BASE_URL=https://clients.lean-101.com.au +ORIGIN=https://clients.example.com +PUBLIC_API_BASE_URL=https://clients.example.com INTERNAL_API_BASE_URL=http://backend:8000 -CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au +CORS_ALLOW_ORIGINS=https://clients.example.com +CORS_ALLOW_ORIGIN_REGEX= +TRUSTED_HOSTS=clients.example.com CLIENTS_APP_PORT=8081 +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_SAMESITE=lax +SESSION_COOKIE_DOMAIN= +SESSION_TTL_SECONDS=43200 +REQUEST_BODY_MAX_BYTES=1048576 +LOGIN_RATE_LIMIT_ATTEMPTS=8 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 +DOCS_ENABLED=false PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false diff --git a/.gitignore b/.gitignore index 3975dc2..6d59095 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,20 @@ __pycache__/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +*.egg-info/ +*.log dist/ build/ node_modules/ .svelte-kit/ backend/.venv/ backend/.pytest_cache/ +backend/.tmp/ +backend/pytest-cache-files-*/ +backend/tests/__pycache__/ frontend/node_modules/ +frontend/.vite/ +frontend/coverage/ *.pyc *.pyo *.pyd @@ -17,4 +24,4 @@ frontend/node_modules/ *.db .env.production .env.alpha - +.env diff --git a/CLAUDE.MD b/CLAUDE.MD index 52bf0a3..0f7a4a1 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -1,3 +1,112 @@ +## Repository operations + +### Dependencies + +Current app dependency entry points: + +| Area | File | Notes | +| --- | --- | --- | +| Frontend runtime + tooling | `frontend/package.json` | SvelteKit app, Vite build, Vitest tests | +| Frontend lockfile | `frontend/package-lock.json` | Generated by npm, commit this with dependency changes | +| Backend runtime + tooling | `backend/pyproject.toml` | FastAPI app, SQLAlchemy, pytest, packaging metadata | + +Current declared dependencies: + +#### Frontend + +- Runtime: `lucide-svelte` +- Dev/build: `@sveltejs/adapter-auto`, `@sveltejs/adapter-node`, `@sveltejs/kit`, `svelte`, `typescript`, `vite`, `vitest` + +#### Backend + +- Runtime/tooling: `fastapi`, `openpyxl`, `rich`, `uvicorn[standard]`, `sqlalchemy`, `pydantic`, `psycopg[binary]`, `reportlab` +- Test dependency: `pytest` + +### Dependency update workflow + +Use a small, controlled update flow rather than bulk-upgrading everything immediately before production. + +#### Frontend + +Check what is outdated: + +```bash +cd frontend +npm outdated +``` + +Install targeted upgrades: + +```bash +npm install @latest +``` + +For a broader refresh within `package.json` ranges: + +```bash +npm update +``` + +Then verify: + +```bash +npm run test +npm run build +``` + +#### Backend + +Check current declared versions in: + +```bash +backend/pyproject.toml +``` + +Upgrade by editing version ranges in `backend/pyproject.toml`, then reinstall: + +```bash +cd backend +pip install -e . +pytest +``` + +If a backend dependency is high-risk near production, prefer upgrading one package at a time and re-running API tests after each change. + +### Repository hygiene + +The repo should keep source code and deployment assets, but not generated local artifacts. + +Expected long-lived top-level folders: + +- `backend/` +- `frontend/` +- `deploy/` + +Expected long-lived top-level docs/config files: + +- `README.md` +- `CLAUDE.MD` +- `docker-compose*.yml` +- `.env*.example` + +Files that should stay out of version control or be moved out of the project root over time: + +- SQLite databases such as `data_entry_app.db` +- local cache folders such as `.pytest_cache/` and `pytest-cache-files-*` +- virtual environments such as `.venv/` +- one-off working assets such as loose spreadsheets, image exports, or temporary notes unless they are intentional project deliverables + +### Tests and pytest files + +There are not many real pytest source files in this repo right now. + +Current actual backend tests: + +- `backend/tests/test_access.py` +- `backend/tests/test_costing_engine.py` + +Most of the extra `pytest`-named items are generated cache/temp directories from local test runs, not hand-written test suites. + ## Spreadsheet analysis summary The workbook is effectively a costing and pricing model with three core calculation layers: diff --git a/Imagotipo-azul.png b/Imagotipo-azul.png deleted file mode 100644 index 2c6ca39..0000000 Binary files a/Imagotipo-azul.png and /dev/null differ diff --git a/README.md b/README.md index f45a4d8..0e4c26b 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,31 @@ pytest The backend defaults to SQLite for the prototype and can be switched with the `DATABASE_URL` environment variable. +### Backend logging + +The backend now uses a shared console logger with a styled startup banner, concise request logs, and clean shutdown summaries. + +Useful logging controls: + +```bash +APP_ENV=production +LOG_LEVEL=INFO +LOG_VERBOSE=1 +NO_COLOR=1 +``` + +- `LOG_LEVEL` sets the base Python log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`). +- `LOG_VERBOSE=1` enables extra startup and route detail without changing normal request noise. +- `NO_COLOR=1` disables colours automatically for plain terminals, Docker log collection, or CI output. +- Colours are also disabled automatically when output is not a TTY. + +Typical local development run: + +```bash +cd backend +LOG_VERBOSE=1 uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + ## Frontend Install dependencies and start the dev server: diff --git a/backend/Dockerfile b/backend/Dockerfile index 0b37be4..4fdf808 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,11 +5,18 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +RUN addgroup --system app && adduser --system --ingroup app app + COPY backend /app RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir . + pip install --no-cache-dir . && \ + chown -R app:app /app + +USER app EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" + CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/access.py b/backend/app/api/access.py index 7fa9726..e113419 100644 --- a/backend/app/api/access.py +++ b/backend/app/api/access.py @@ -7,7 +7,7 @@ the current user has, then use those keys to hide/show navigation items. """ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session, selectinload @@ -21,12 +21,19 @@ from app.core.access import ( require_permission, ) from app.core.config import settings +from app.core.http import CLIENT_AUTH_COOKIE +from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key +from app.core.security_logging import log_security_event from app.core.security import hash_password, issue_token, verify_password from app.db.session import get_db from app.models.access import Permission, Role, User router = APIRouter(prefix="/api/access", tags=["access"]) +login_rate_limiter = SlidingWindowRateLimiter( + limit=settings.login_rate_limit_attempts, + window_seconds=settings.login_rate_limit_window_seconds, +) class LoginRequest(BaseModel): @@ -75,7 +82,10 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio role_name = user.role.name if user.role else None token = None if include_token: - token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email}) + token = issue_token( + {"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email}, + ttl_seconds=settings.session_ttl_seconds, + ) # role="internal" is a marker the shared auth deps recognise so internal # users can hit the same routes as client-portal users without being # confused with them. Display name lives in role_name / client_role. @@ -96,14 +106,16 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio @router.post("/login", response_model=UserSession) -def login(payload: LoginRequest, db: Session = Depends(get_db)): +def login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)): """Internal-user login. Authenticates against a shared internal password (``ADMIN_PASSWORD``) and looks up the user by email. Inactive or unknown users are rejected with a generic 401 to avoid leaking which emails are valid. """ + login_rate_limiter.hit(request_client_key(request, suffix="internal-login")) if payload.password != settings.admin_password: + log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") email = payload.email.strip().lower() @@ -113,15 +125,20 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)): .options(selectinload(User.role).selectinload(Role.permissions)) ) if user is None or not user.is_active: + log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") - return _serialize_session(user, include_token=True) + session = _serialize_session(user, include_token=True) + if session.token: + CLIENT_AUTH_COOKIE.apply(response, session.token) + log_security_event("auth.login_succeeded", audience="internal", role=user.role.name if user.role else None, user_id=user.id) + return session.model_copy(update={"token": None}) @router.get("/me", response_model=UserSession) def read_me(user: User = Depends(get_current_user)): """Return the current user with permission keys for UI navigation gating.""" - return _serialize_session(user) + return _serialize_session(user).model_copy(update={"token": None}) @router.get("/me/permissions", response_model=list[str]) @@ -181,7 +198,14 @@ def update_me( db.commit() db.refresh(user) - return _serialize_session(user, include_token=True) + return _serialize_session(user, include_token=True).model_copy(update={"token": None}) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +def logout(response: Response): + CLIENT_AUTH_COOKIE.clear(response) + response.status_code = status.HTTP_204_NO_CONTENT + return None # Permission-enforced administrative endpoints. Route bodies should not check diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 906d202..d87cb7c 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,16 +1,23 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import AuthSession, require_admin_session, require_client_session from app.core.config import settings +from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE +from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key +from app.core.security_logging import log_security_event from app.core.security import issue_token from app.db.session import get_db from app.models.client_access import ClientAccount from app.services.client_access_service import get_client_user_by_email, module_access_map router = APIRouter(prefix="/api/auth", tags=["auth"]) +login_rate_limiter = SlidingWindowRateLimiter( + limit=settings.login_rate_limit_attempts, + window_seconds=settings.login_rate_limit_window_seconds, +) class LoginRequest(BaseModel): @@ -27,7 +34,7 @@ class SessionResponse(BaseModel): user_id: int | None = None client_account_id: int | None = None module_permissions: dict[str, str] = Field(default_factory=dict) - token: str + token: str | None = None def _build_session_response( @@ -50,7 +57,8 @@ def _build_session_response( "client_role": client_role, "user_id": user_id, "client_account_id": client_account_id, - } + }, + ttl_seconds=settings.session_ttl_seconds, ) return SessionResponse( name=name, @@ -66,19 +74,22 @@ def _build_session_response( @router.post("/client/login", response_model=SessionResponse) -def client_login(payload: LoginRequest, db: Session = Depends(get_db)): +def client_login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)): + login_rate_limiter.hit(request_client_key(request, suffix="client-login")) if payload.password != settings.client_password: + log_security_event("auth.login_failed", audience="client", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password") user = get_client_user_by_email(db, email=payload.email.strip().lower()) if user is None: + log_security_event("auth.login_failed", audience="client", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password") client_account = db.scalar(select(ClientAccount).where(ClientAccount.id == user.client_account_id)) if client_account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured for this user") - return _build_session_response( + session_response = _build_session_response( name=user.full_name, email=user.email, role="client", @@ -88,14 +99,24 @@ def client_login(payload: LoginRequest, db: Session = Depends(get_db)): client_account_id=client_account.id, module_permissions=module_access_map(user), ) + if session_response.token: + CLIENT_AUTH_COOKIE.apply(response, session_response.token) + log_security_event("auth.login_succeeded", audience="client", role="client", user_id=user.id, tenant_id=client_account.tenant_id) + return session_response.model_copy(update={"token": None}) @router.post("/admin/login", response_model=SessionResponse) -def admin_login(payload: LoginRequest): +def admin_login(payload: LoginRequest, response: Response, request: Request): + login_rate_limiter.hit(request_client_key(request, suffix="admin-login")) if payload.email.strip().lower() != settings.admin_email.lower() or payload.password != settings.admin_password: + log_security_event("auth.login_failed", audience="admin", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin email or password") - return _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin") + session_response = _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin") + if session_response.token: + ADMIN_AUTH_COOKIE.apply(response, session_response.token) + log_security_event("auth.login_succeeded", audience="admin", role="admin") + return session_response.model_copy(update={"token": None}) @router.get("/client/session", response_model=SessionResponse) @@ -112,9 +133,23 @@ def read_client_session(session: AuthSession = Depends(require_client_session), user_id=user.id, client_account_id=user.client_account_id, module_permissions=module_access_map(user), - ) + ).model_copy(update={"token": None}) @router.get("/admin/session", response_model=SessionResponse) def read_admin_session(session: AuthSession = Depends(require_admin_session)): - return _build_session_response(name=session.name, email=session.email, role=session.role) + return _build_session_response(name=session.name, email=session.email, role=session.role).model_copy(update={"token": None}) + + +@router.post("/client/logout", status_code=status.HTTP_204_NO_CONTENT) +def client_logout(response: Response): + CLIENT_AUTH_COOKIE.clear(response) + response.status_code = status.HTTP_204_NO_CONTENT + return None + + +@router.post("/admin/logout", status_code=status.HTTP_204_NO_CONTENT) +def admin_logout(response: Response): + ADMIN_AUTH_COOKIE.clear(response) + response.status_code = status.HTTP_204_NO_CONTENT + return None diff --git a/backend/app/api/client_access.py b/backend/app/api/client_access.py index 17784e1..c1cea7c 100644 --- a/backend/app/api/client_access.py +++ b/backend/app/api/client_access.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, selectinload @@ -78,10 +78,11 @@ def _actor_metadata(session: AuthSession) -> dict[str, str]: @router.get("", response_model=list[ClientAccessRead]) def get_client_access( + limit: int = Query(default=100, ge=1, le=200), db: Session = Depends(get_db), session: AuthSession = Depends(require_client_access_manager_session), ): - return [serialize_client_account(client) for client in _authorized_client_scope(db, session)] + return [serialize_client_account(client) for client in _authorized_client_scope(db, session)[:limit]] @router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED) diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 0988b15..567aa2a 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from app.api.deps import AuthSession, require_client_session +from app.api.deps import AuthSession, require_client_module_access from app.db.session import get_db from app.models.mix import Mix from app.models.product import Product @@ -35,7 +35,7 @@ def _can(session: AuthSession, module_key: str) -> bool: @router.get("/summary") def dashboard_summary( - session: AuthSession = Depends(require_client_session), + session: AuthSession = Depends(require_client_module_access("dashboard")), db: Session = Depends(get_db), ): raw_materials_summary: dict | None = None diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index de153d7..5b9a8b5 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -2,8 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi import Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload @@ -13,14 +12,14 @@ from app.core.access import ( get_user_permissions, permissions_to_module_map, ) +from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE, get_bearer_or_cookie_token +from app.core.security_logging import log_security_event from app.core.security import verify_token from app.db.session import get_db from app.models.access import Role, User from app.models.client_access import ClientFeatureAccess, ClientUser from app.services.client_access_service import has_access_level, module_access_map -bearer_scheme = HTTPBearer(auto_error=False) - @dataclass(frozen=True) class AuthSession: @@ -67,13 +66,16 @@ def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession: def get_auth_session( - credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + request: Request, db: Session = Depends(get_db), ) -> AuthSession: - if credentials is None: + token = get_bearer_or_cookie_token(request, cookie_name=CLIENT_AUTH_COOKIE.name) or get_bearer_or_cookie_token( + request, cookie_name=ADMIN_AUTH_COOKIE.name + ) + if token is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") - payload = verify_token(credentials.credentials) + payload = verify_token(token) # Internal Hunter Stock Feeds users get an auth session derived from the # role/permission tables rather than the client-portal ClientUser tables. @@ -111,6 +113,7 @@ def require_client_session(session: AuthSession = Depends(get_auth_session)) -> def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession: if session.role != "admin": + log_security_event("authz.denied", role=session.role, required="admin") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return session @@ -143,6 +146,7 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"): if session.role == "internal": permissions = session.module_permissions or {} if not has_access_level(permissions.get(module_key), minimum_level): + log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted", @@ -158,10 +162,12 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"): ) ) if feature is not None and not feature.enabled: + log_security_event("authz.denied", role=session.role, module=module_key, reason="feature_disabled") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} is disabled for this client") permissions = module_access_map(user) if not has_access_level(permissions.get(module_key), minimum_level): + log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted") return AuthSession( @@ -190,6 +196,7 @@ def require_client_access_manager_session( user = load_current_client_user(db, require_client_session(session)) permissions = module_access_map(user) if user.role != "superadmin" or not has_access_level(permissions.get("client_access"), "manage"): + log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required") return AuthSession( diff --git a/backend/app/api/mix_calculator.py b/backend/app/api/mix_calculator.py index eab437b..6f4549f 100644 --- a/backend/app/api/mix_calculator.py +++ b/backend/app/api/mix_calculator.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session from app.api.deps import AuthSession, require_client_module_access @@ -37,10 +37,11 @@ def mix_calculator_options( @router.get("", response_model=list[MixCalculatorSessionSummaryRead]) def mix_calculator_sessions( + limit: int = Query(default=100, ge=1, le=200), session: AuthSession = Depends(require_client_module_access("mix_calculator")), db: Session = Depends(get_db), ): - return list_mix_calculator_sessions(db, auth_session=session) + return list_mix_calculator_sessions(db, auth_session=session, limit=limit) @router.post("/preview", response_model=MixCalculatorPreviewRead) diff --git a/backend/app/api/mixes.py b/backend/app/api/mixes.py index 0ed1e63..a279f4b 100644 --- a/backend/app/api/mixes.py +++ b/backend/app/api/mixes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -13,8 +13,12 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"]) @router.get("", response_model=list[MixRead]) -def list_mixes(session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)): - mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all() +def list_mixes( + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("mix_master")), + db: Session = Depends(get_db), +): + mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name).limit(limit)).all() return [calculate_mix_cost(db, mix.id) for mix in mixes] diff --git a/backend/app/api/products.py b/backend/app/api/products.py index 528a368..28f1c1b 100644 --- a/backend/app/api/products.py +++ b/backend/app/api/products.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -23,6 +23,7 @@ def _serialize_product(product: Product) -> dict: "mix_name": product.mix.name if product.mix else "", "sale_type": product.sale_type, "own_bag": product.own_bag, + "visible": product.visible, "unit_of_measure": product.unit_of_measure, "items_per_pallet": product.items_per_pallet, "bagging_process": product.bagging_process, @@ -34,8 +35,12 @@ def _serialize_product(product: Product) -> dict: @router.get("", response_model=list[ProductRead]) -def list_products(session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)): - products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all() +def list_products( + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("products")), + db: Session = Depends(get_db), +): + products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name).limit(limit)).all() return [_serialize_product(product) for product in products] diff --git a/backend/app/api/raw_materials.py b/backend/app/api/raw_materials.py index 5047662..63bdf49 100644 --- a/backend/app/api/raw_materials.py +++ b/backend/app/api/raw_materials.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload @@ -34,12 +34,17 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d @router.get("", response_model=list[RawMaterialRead]) -def list_raw_materials(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)): +def list_raw_materials( + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("raw_materials")), + db: Session = Depends(get_db), +): materials = db.scalars( select(RawMaterial) .where(RawMaterial.tenant_id == session.tenant_id) .options(selectinload(RawMaterial.price_versions)) .order_by(RawMaterial.name) + .limit(limit) ).all() return [serialize_raw_material(material) for material in materials] @@ -130,7 +135,12 @@ def add_price_version( @router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead]) -def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)): +def get_price_history( + raw_material_id: int, + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("raw_materials")), + db: Session = Depends(get_db), +): material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)) if material is None: raise HTTPException(status_code=404, detail="Raw material not found") @@ -141,6 +151,7 @@ def get_price_history(raw_material_id: int, session: AuthSession = Depends(requi RawMaterialPriceVersion.tenant_id == session.tenant_id, ) .order_by(RawMaterialPriceVersion.effective_date.desc()) + .limit(limit) ).all() items = [] for price in prices: diff --git a/backend/app/api/scenarios.py b/backend/app/api/scenarios.py index 22339ab..e6c786d 100644 --- a/backend/app/api/scenarios.py +++ b/backend/app/api/scenarios.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -12,8 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) @router.get("", response_model=list[ScenarioRead]) -def list_scenarios(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)): - return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all() +def list_scenarios( + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("scenarios")), + db: Session = Depends(get_db), +): + return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc()).limit(limit)).all() @router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED) diff --git a/backend/app/core/access.py b/backend/app/core/access.py index 4ba32b1..4ba950c 100644 --- a/backend/app/core/access.py +++ b/backend/app/core/access.py @@ -16,18 +16,16 @@ from __future__ import annotations from typing import Iterable -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi import Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from app.core.security import verify_token +from app.core.http import CLIENT_AUTH_COOKIE, get_bearer_or_cookie_token +from app.core.security_logging import log_security_event from app.db.session import get_db from app.models.access import Permission, Role, User - -bearer_scheme = HTTPBearer(auto_error=False) - # Subject claim used by tokens issued for internal Hunter Stock Feeds users. # Distinct from the existing client-portal/admin tokens so the two systems # cannot impersonate each other. @@ -103,7 +101,7 @@ def _load_user(db: Session, user_id: int) -> User | None: def get_current_user( - credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + request: Request, db: Session = Depends(get_db), ) -> User: """Resolve the current internal user from the bearer token. @@ -111,10 +109,11 @@ def get_current_user( Raises 401 for missing/invalid tokens or unknown users, 403 for inactive users. """ - if credentials is None: + token = get_bearer_or_cookie_token(request, cookie_name=CLIENT_AUTH_COOKIE.name) + if token is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") - payload = verify_token(credentials.credentials) + payload = verify_token(token) if payload.get("sub") != INTERNAL_USER_SUBJECT: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token") @@ -136,6 +135,7 @@ def require_permission(permission_key: str): def dependency(user: User = Depends(get_current_user)) -> User: if not user_has_permission(user, permission_key): + log_security_event("authz.denied", role=user.role.name if user.role else None, permission=permission_key) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing required permission: {permission_key}", @@ -152,6 +152,7 @@ def require_any_permission(permission_keys: Iterable[str]): def dependency(user: User = Depends(get_current_user)) -> User: granted = get_user_permissions(user) if not any(key in granted for key in keys): + log_security_event("authz.denied", role=user.role.name if user.role else None, permissions=list(keys)) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires any of: {list(keys)}", @@ -169,6 +170,7 @@ def require_all_permissions(permission_keys: Iterable[str]): granted = get_user_permissions(user) missing = [key for key in keys if key not in granted] if missing: + log_security_event("authz.denied", role=user.role.name if user.role else None, permissions=missing) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing required permissions: {missing}", diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 624bd3b..a2d6303 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -16,9 +16,21 @@ def _parse_csv_env(value: str) -> tuple[str, ...]: return tuple(part.strip() for part in value.split(",") if part.strip()) +def _env_flag(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + @dataclass(frozen=True) class Settings: app_name: str + app_env: str + host: str + port: int + log_level: str + log_verbose: bool database_url: str client_name: str client_email: str @@ -30,11 +42,27 @@ class Settings: auth_secret: str cors_allow_origins: tuple[str, ...] cors_allow_origin_regex: str + session_ttl_seconds: int + session_cookie_name: str + admin_session_cookie_name: str + session_cookie_secure: bool + session_cookie_samesite: str + session_cookie_domain: str | None + request_body_max_bytes: int + login_rate_limit_attempts: int + login_rate_limit_window_seconds: int + trusted_hosts: tuple[str, ...] + docs_enabled: bool @classmethod def from_env(cls) -> "Settings": - return cls( + settings = cls( app_name=os.getenv("APP_NAME", "Data Entry App API"), + app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")), + host=os.getenv("HOST", "0.0.0.0"), + port=int(os.getenv("PORT", "8000")), + log_level=os.getenv("LOG_LEVEL", "DEBUG" if os.getenv("LOG_VERBOSE") in {"1", "true", "TRUE", "yes", "on"} else "INFO"), + log_verbose=_env_flag("LOG_VERBOSE"), database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"), client_name=os.getenv("CLIENT_NAME", "Hunter Premium Produce"), client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"), @@ -51,7 +79,47 @@ class Settings: ) ), cors_allow_origin_regex=os.getenv("CORS_ALLOW_ORIGIN_REGEX", DEFAULT_CORS_ALLOW_ORIGIN_REGEX), + session_ttl_seconds=int(os.getenv("SESSION_TTL_SECONDS", str(60 * 60 * 12))), + session_cookie_name=os.getenv("SESSION_COOKIE_NAME", "client_session"), + admin_session_cookie_name=os.getenv("ADMIN_SESSION_COOKIE_NAME", "admin_session"), + session_cookie_secure=_env_flag("SESSION_COOKIE_SECURE"), + session_cookie_samesite=os.getenv("SESSION_COOKIE_SAMESITE", "lax").lower(), + session_cookie_domain=os.getenv("SESSION_COOKIE_DOMAIN", "").strip() or None, + request_body_max_bytes=int(os.getenv("REQUEST_BODY_MAX_BYTES", str(1024 * 1024))), + login_rate_limit_attempts=int(os.getenv("LOGIN_RATE_LIMIT_ATTEMPTS", "8")), + login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")), + trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")), + docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"), ) + settings._validate() + return settings + + def _validate(self) -> None: + if self.session_cookie_samesite not in {"lax", "strict", "none"}: + raise ValueError("SESSION_COOKIE_SAMESITE must be one of: lax, strict, none") + + is_production = self.app_env.lower() == "production" + if not is_production: + return + + if self.client_password in {"changeme", "", "replace-with-strong-password"}: + raise ValueError("CLIENT_PASSWORD must be set to a non-default value in production") + if self.admin_password in {"lean101-admin", "", "replace-with-strong-password"}: + raise ValueError("ADMIN_PASSWORD must be set to a non-default value in production") + if self.auth_secret in {"lean-101-local-dev-secret", "change-me-in-production", "", "replace-with-a-long-random-secret"}: + raise ValueError("AUTH_SECRET must be set to a strong production secret") + if len(self.auth_secret) < 32: + raise ValueError("AUTH_SECRET must be at least 32 characters in production") + if not self.session_cookie_secure: + raise ValueError("SESSION_COOKIE_SECURE must be enabled in production") + if not self.cors_allow_origins: + raise ValueError("CORS_ALLOW_ORIGINS must explicitly list production origins") + if "localhost" in ",".join(self.cors_allow_origins).lower(): + raise ValueError("CORS_ALLOW_ORIGINS cannot include localhost in production") + if self.cors_allow_origin_regex == DEFAULT_CORS_ALLOW_ORIGIN_REGEX: + raise ValueError("CORS_ALLOW_ORIGIN_REGEX must be overridden or blank in production") + if self.docs_enabled: + raise ValueError("DOCS_ENABLED must be false in production") settings = Settings.from_env() diff --git a/backend/app/core/http.py b/backend/app/core/http.py new file mode 100644 index 0000000..fd75a0e --- /dev/null +++ b/backend/app/core/http.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from fastapi import Request, Response + +from app.core.config import settings + + +COOKIE_PATH: Final[str] = "/" + + +@dataclass(frozen=True) +class AuthCookie: + name: str + + def apply(self, response: Response, token: str) -> None: + response.set_cookie( + key=self.name, + value=token, + httponly=True, + secure=settings.session_cookie_secure, + samesite=settings.session_cookie_samesite, + domain=settings.session_cookie_domain, + path=COOKIE_PATH, + max_age=settings.session_ttl_seconds, + ) + + def clear(self, response: Response) -> None: + response.delete_cookie( + key=self.name, + domain=settings.session_cookie_domain, + path=COOKIE_PATH, + ) + + +CLIENT_AUTH_COOKIE = AuthCookie(settings.session_cookie_name) +ADMIN_AUTH_COOKIE = AuthCookie(settings.admin_session_cookie_name) + + +def get_bearer_or_cookie_token(request: Request, *, cookie_name: str) -> str | None: + authorization = request.headers.get("authorization", "").strip() + if authorization.lower().startswith("bearer "): + token = authorization[7:].strip() + if token: + return token + cookie_value = request.cookies.get(cookie_name) + if cookie_value: + return cookie_value + return None diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..1d97a6b --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging +import os +import sys +import time +from typing import Iterable + +try: + from rich.console import Console + from rich.logging import RichHandler + from rich.table import Table + from rich.text import Text +except ImportError: # pragma: no cover - exercised only before dependency install + Console = None + RichHandler = None + Table = None + Text = None + + +@dataclass(frozen=True) +class LoggingSettings: + app_name: str + app_env: str + host: str + port: int + log_level: str + log_verbose: bool + database_url: str + version: str + + +@dataclass(frozen=True) +class StartupStatus: + app_name: str + version: str + environment: str + host: str + port: int + database: str + mode: str + started_at: str + local_url: str + network_url: str + + +class PlainFormatter(logging.Formatter): + default_time_format = "%Y-%m-%d %H:%M:%S" + + def format(self, record: logging.LogRecord) -> str: + if not hasattr(record, "component"): + record.component = record.name.rsplit(".", 1)[-1] + return super().format(record) + + +def _allow_color() -> bool: + if RichHandler is None or Console is None: + return False + if os.getenv("NO_COLOR"): + return False + if os.getenv("TERM") == "dumb": + return False + return hasattr(sys.stderr, "isatty") and sys.stderr.isatty() + + +def _allow_unicode() -> bool: + encoding = (getattr(sys.stdout, "encoding", None) or "").lower() + if not encoding: + return False + return "utf" in encoding + + +def _console() -> Console: + return Console( + stderr=True, + soft_wrap=False, + highlight=False, + force_terminal=_allow_color(), + no_color=not _allow_color(), + emoji=False, + ) + + +def _rich_handler(level: str) -> RichHandler: + return RichHandler( + level=level, + console=_console(), + show_time=True, + show_level=True, + show_path=False, + omit_repeated_times=False, + markup=True, + rich_tracebacks=True, + tracebacks_show_locals=False, + log_time_format="%H:%M:%S", + ) + + +def _plain_handler(level: str) -> logging.StreamHandler: + handler = logging.StreamHandler() + handler.setLevel(level) + handler.setFormatter( + PlainFormatter("%(asctime)s | %(levelname)-7s | %(component)-10s | %(message)s") + ) + return handler + + +def _handler(level: str) -> logging.Handler: + return _rich_handler(level) if _allow_color() else _plain_handler(level) + + +def configure_logging(settings: LoggingSettings) -> None: + level = settings.log_level.upper() + root = logging.getLogger() + root.handlers.clear() + root.setLevel(level) + root.addHandler(_handler(level)) + + for name in ("uvicorn", "uvicorn.error", "fastapi"): + logger = logging.getLogger(name) + logger.handlers.clear() + logger.setLevel(level) + logger.propagate = True + + access_logger = logging.getLogger("uvicorn.access") + access_logger.handlers.clear() + access_logger.propagate = False + access_logger.disabled = True + + +def get_logger(name: str) -> logging.LoggerAdapter[logging.Logger]: + component = name.rsplit(".", 1)[-1] + return logging.LoggerAdapter(logging.getLogger(name), {"component": component}) + + +def _icon(name: str) -> str: + ascii_icons = { + "app": "#", + "info": "i", + "success": "+", + "warning": "!", + "error": "x", + "debug": ".", + "section": "=", + "url": ">", + "shutdown": "-", + } + unicode_icons = { + "app": "◆", + "info": "ℹ", + "success": "✓", + "warning": "▲", + "error": "✖", + "debug": "•", + "section": "─", + "url": "↳", + "shutdown": "◦", + } + icons = unicode_icons if _allow_unicode() else ascii_icons + return icons[name] + + +def _style(name: str) -> str: + return { + "info": "bold cyan", + "success": "bold green", + "warning": "bold yellow", + "error": "bold red", + "debug": "dim", + "section": "bold bright_blue", + "muted": "grey62", + }[name] + + +def section_heading(title: str) -> None: + logger = get_logger("data_entry_app.section") + if _allow_color(): + _console().rule(Text(f" {title.upper()} ", style=_style("section"))) + return + logger.info("%s %s %s", _icon("section") * 10, title.upper(), _icon("section") * 10) + + +def startup_banner(status: StartupStatus) -> None: + logger = get_logger("data_entry_app.startup") + if _allow_color(): + console = _console() + table = Table.grid(expand=False) + table.add_column(style="bold white", justify="left") + table.add_column(style="white", justify="left") + table.add_row("Environment", status.environment) + table.add_row("Version", status.version) + table.add_row("Host", status.host) + table.add_row("Port", str(status.port)) + table.add_row("Database", status.database) + table.add_row("Mode", status.mode) + table.add_row("Started", status.started_at) + + console.rule(Text(f" {status.app_name} ", style="bold white")) + console.print(Text("Clean startup. Clear status. Ready.", style="italic cyan")) + console.print(table) + console.print() + console.print(Text("App is running at:", style="bold white")) + console.print(Text(f" Local: {status.local_url}", style="cyan")) + console.print(Text(f" Network: {status.network_url}", style="cyan")) + console.print() + return + + logger.info("%s %s", _icon("app"), "Startup banner") + logger.info("App : %s", status.app_name) + logger.info("Environment : %s", status.environment) + logger.info("Version : %s", status.version) + logger.info("Host : %s", status.host) + logger.info("Port : %s", status.port) + logger.info("Database : %s", status.database) + logger.info("Mode : %s", status.mode) + logger.info("Started : %s", status.started_at) + logger.info("Local : %s", status.local_url) + logger.info("Network : %s", status.network_url) + + +def status_message(level: str, message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + palette = { + "debug": logging.DEBUG, + "info": logging.INFO, + "success": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + } + labels = { + "debug": f"[{_icon('debug')}]", + "info": f"[{_icon('info')}]", + "success": f"[{_icon('success')}]", + "warning": f"[{_icon('warning')}]", + "error": f"[{_icon('error')}]", + } + styles = { + "debug": _style("debug"), + "info": _style("info"), + "success": _style("success"), + "warning": _style("warning"), + "error": _style("error"), + } + logger = get_logger(logger_name) + rendered = message % args if args else message + if _allow_color(): + logger.log(palette[level], f"[{styles[level]}]{labels[level]}[/] {rendered}") + else: + logger.log(palette[level], "%s %s", labels[level], rendered) + + +def success(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + status_message("success", message, *args, logger_name=logger_name) + + +def warning(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + status_message("warning", message, *args, logger_name=logger_name) + + +def info(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + status_message("info", message, *args, logger_name=logger_name) + + +def debug(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + status_message("debug", message, *args, logger_name=logger_name) + + +def fatal(message: str, *args: object, exc_info: bool = False, logger_name: str = "data_entry_app.status") -> None: + logger = get_logger(logger_name) + rendered = message % args if args else message + if _allow_color(): + logger.error(f"[{_style('error')}][{_icon('error')}][/] {rendered}", exc_info=exc_info) + else: + logger.error("[%s] %s", _icon("error"), rendered, exc_info=exc_info) + + +def shutdown_summary(*, uptime_seconds: float, requests_served: int, host: str, port: int) -> None: + section_heading("Shutdown") + logger = get_logger("data_entry_app.shutdown") + summary = f"Uptime {uptime_seconds:.1f}s | Requests {requests_served} | Endpoint http://{host}:{port}" + if _allow_color(): + logger.info(f"[{_style('debug')}]{_icon('shutdown')}[/] {summary}") + else: + logger.info("%s %s", _icon("shutdown"), summary) + + +def describe_database(url: str) -> str: + if url.startswith("sqlite"): + return "sqlite" + if "postgresql" in url: + return "postgresql" + if "mysql" in url: + return "mysql" + return url.split(":", 1)[0] + + +def sanitize_database_target(url: str) -> str: + if url.startswith("sqlite:///"): + return url.removeprefix("sqlite:///") + if "@" in url: + return url.split("@", 1)[1] + return url + + +def startup_status(settings: LoggingSettings) -> StartupStatus: + host = settings.host + local_host = "localhost" if host in {"0.0.0.0", "::"} else host + timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") + return StartupStatus( + app_name=settings.app_name, + version=settings.version, + environment=settings.app_env, + host=settings.host, + port=settings.port, + database=f"{describe_database(settings.database_url)} ({sanitize_database_target(settings.database_url)})", + mode="verbose" if settings.log_verbose else "normal", + started_at=timestamp, + local_url=f"http://{local_host}:{settings.port}", + network_url=f"http://{host}:{settings.port}", + ) + + +def route_summary(routes: Iterable[object]) -> tuple[int, list[str]]: + lines: list[str] = [] + count = 0 + for route in routes: + path = getattr(route, "path", None) + methods = getattr(route, "methods", None) + if not path or not methods: + continue + filtered_methods = sorted(method for method in methods if method not in {"HEAD", "OPTIONS"}) + if not filtered_methods: + continue + count += 1 + lines.append(f"{','.join(filtered_methods):<7} {path}") + return count, lines + + +def log_request( + *, + method: str, + path: str, + status_code: int, + duration_ms: float, + client: str, + content_length: str | None, +) -> None: + level = "info" + if status_code >= 500: + level = "error" + elif status_code >= 400: + level = "warning" + elif path == "/health": + level = "debug" + + message = ( + f"{method:<6} {status_code:>3} {duration_ms:>7.1f}ms " + f"{path:<36} client={client}" + ) + if content_length: + message += f" bytes={content_length}" + status_message(level, message, logger_name="data_entry_app.http") + + +class RequestTimer: + def __init__(self) -> None: + self.started = time.perf_counter() + + @property + def elapsed_ms(self) -> float: + return (time.perf_counter() - self.started) * 1000 diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..485d075 --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass +from threading import Lock + +from fastapi import HTTPException, Request, status + + +@dataclass +class SlidingWindowRateLimiter: + limit: int + window_seconds: int + + def __post_init__(self) -> None: + self._events: dict[str, deque[float]] = {} + self._lock = Lock() + + def hit(self, key: str) -> None: + now = time.time() + floor = now - self.window_seconds + + with self._lock: + bucket = self._events.setdefault(key, deque()) + while bucket and bucket[0] <= floor: + bucket.popleft() + if len(bucket) >= self.limit: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests. Please try again later.", + ) + bucket.append(now) + + +def request_client_key(request: Request, *, suffix: str = "") -> str: + forwarded_for = request.headers.get("x-forwarded-for", "") + client_ip = forwarded_for.split(",", 1)[0].strip() if forwarded_for else (request.client.host if request.client else "unknown") + return f"{client_ip}:{suffix}" if suffix else client_ip diff --git a/backend/app/core/security_logging.py b/backend/app/core/security_logging.py new file mode 100644 index 0000000..985978b --- /dev/null +++ b/backend/app/core/security_logging.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import logging + + +logger = logging.getLogger("data_entry_app.security") + + +def log_security_event(event: str, **fields: object) -> None: + safe_fields = { + key: value + for key, value in fields.items() + if key not in {"password", "token", "cookie", "authorization"} + } + logger.info("%s | %s", event, safe_fields) diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 7c0fc52..84d52a1 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -2,9 +2,19 @@ from __future__ import annotations from dataclasses import dataclass, field -from sqlalchemy import MetaData, inspect, text +from sqlalchemy import MetaData, bindparam, inspect, text from sqlalchemy.engine import Engine +HIDDEN_PRODUCT_CLIENTS = ( + "Bird Grits", + "Chaff", + "Hay & Straw", + "Hunter Premium Produce", + "Straight Grain", + "Uncategorized", + "Uncategorised", +) + TENANT_TABLES = { "client_users": None, @@ -88,6 +98,7 @@ def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]: # introduced on the model. Each entry is (table, column, DDL fragment). _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = ( ("users", "password_hash", "VARCHAR(255)"), + ("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"), ) @@ -359,6 +370,24 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]: return synced_rows +def sync_product_visibility(engine: Engine) -> int: + if not _table_exists(engine, "products") or not _has_column(engine, "products", "visible"): + return 0 + + with engine.begin() as connection: + result = connection.execute( + text( + """ + UPDATE products + SET visible = FALSE + WHERE client_name IN :hidden_clients + AND (visible IS NULL OR visible != FALSE) + """ + ).bindparams(bindparam("hidden_clients", value=HIDDEN_PRODUCT_CLIENTS, expanding=True)) + ) + return result.rowcount or 0 + + def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport: created_tables = ensure_metadata_tables(engine, metadata) added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine) diff --git a/backend/app/main.py b/backend/app/main.py index a7a43d8..4adcf04 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,17 +1,23 @@ import logging -import os +import re import sys from contextlib import asynccontextmanager +from importlib.metadata import PackageNotFoundError, version as package_version from pathlib import Path from threading import Lock +from typing import Final if __package__ in {None, ""}: sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from fastapi import FastAPI +from fastapi import Request +from fastapi import FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.responses import JSONResponse import uvicorn +from app import models as _models # noqa: F401 - ensure all SQLAlchemy models are registered from app.api.access import router as access_router from app.api.auth import router as auth_router from app.api.client_access import router as client_access_router @@ -23,13 +29,64 @@ from app.api.products import router as products_router from app.api.raw_materials import router as raw_materials_router from app.api.scenarios import router as scenarios_router from app.core.config import settings +from app.core.logging import ( + LoggingSettings, + RequestTimer, + configure_logging, + debug, + fatal, + info, + log_request, + route_summary, + section_heading, + shutdown_summary, + startup_banner, + startup_status, + success, +) from app.db.session import Base, engine -from app.db.migrations import MigrationReport, bootstrap_schema, sync_tenant_ids +from app.db.migrations import MigrationReport, bootstrap_schema, sync_product_visibility, sync_tenant_ids from app.seed import seed_if_empty + +def _resolve_version() -> str: + try: + return package_version("data-entry-app-backend") + except PackageNotFoundError: + return "0.0.0" + + +APP_VERSION: Final[str] = _resolve_version() +_logging_settings = LoggingSettings( + app_name=settings.app_name, + app_env=settings.app_env, + host=settings.host, + port=settings.port, + log_level=settings.log_level, + log_verbose=settings.log_verbose, + database_url=settings.database_url, + version=APP_VERSION, +) + +configure_logging(_logging_settings) + logger = logging.getLogger("data_entry_app.startup") _database_ready = False _database_ready_lock = Lock() +_requests_served = 0 + + +def _origin_is_allowed(origin: str | None) -> bool: + if not origin: + return True + + if origin in settings.cors_allow_origins: + return True + + if settings.cors_allow_origin_regex: + return re.fullmatch(settings.cors_allow_origin_regex, origin) is not None + + return False def ensure_database_ready() -> MigrationReport: @@ -45,11 +102,15 @@ def ensure_database_ready() -> MigrationReport: schema_report = bootstrap_schema(engine, Base.metadata) seed_if_empty() tenant_sync_report = sync_tenant_ids(engine) + hidden_product_count = sync_product_visibility(engine) report = MigrationReport( created_tables=schema_report.created_tables, added_columns=schema_report.added_columns, - synced_tenant_rows=tenant_sync_report, + synced_tenant_rows={ + **tenant_sync_report, + **({"products_visibility": hidden_product_count} if hidden_product_count else {}), + }, ) logger.info("Database startup checks complete: %s", report.summary()) _database_ready = True @@ -57,20 +118,72 @@ def ensure_database_ready() -> MigrationReport: @asynccontextmanager -async def lifespan(_: FastAPI): - ensure_database_ready() +async def lifespan(app: FastAPI): + started = startup_status(_logging_settings) + launch_time = RequestTimer() + + startup_banner(started) + section_heading("Startup") + info("Booting %s", settings.app_name, logger_name="data_entry_app.startup") + + section_heading("Configuration") + success("Configuration loaded") + info("CORS origins: %s", ", ".join(settings.cors_allow_origins), logger_name="data_entry_app.config") + if settings.cors_allow_origin_regex: + debug("CORS regex: %s", settings.cors_allow_origin_regex, logger_name="data_entry_app.config") + + section_heading("Database") + try: + report = ensure_database_ready() + except Exception: + fatal("Database startup failed", exc_info=True, logger_name="data_entry_app.database") + raise + success("Database connected") + if report.has_changes(): + info(report.summary(), logger_name="data_entry_app.database") + else: + debug(report.summary(), logger_name="data_entry_app.database") + + section_heading("Routes") + route_count, route_lines = route_summary(app.routes) + success("Routes registered (%s endpoints)", route_count) + if settings.log_verbose: + for route_line in route_lines: + debug(route_line, logger_name="data_entry_app.routes") + + section_heading("Services") + success("HTTP API ready") + info("Docs available at /docs", logger_name="data_entry_app.services") + info("Health probe available at /health", logger_name="data_entry_app.services") + yield + shutdown_summary( + uptime_seconds=launch_time.elapsed_ms / 1000, + requests_served=_requests_served, + host=settings.host, + port=settings.port, + ) -app = FastAPI(title=settings.app_name, lifespan=lifespan) + +app = FastAPI( + title=settings.app_name, + version=APP_VERSION, + lifespan=lifespan, + docs_url="/docs" if settings.docs_enabled else None, + redoc_url=None, + openapi_url="/openapi.json" if settings.docs_enabled else None, +) + +app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(settings.trusted_hosts) or ["*"]) app.add_middleware( CORSMiddleware, allow_origins=list(settings.cors_allow_origins), - allow_origin_regex=settings.cors_allow_origin_regex, + allow_origin_regex=settings.cors_allow_origin_regex or None, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Requested-With"], ) app.include_router(auth_router) @@ -85,6 +198,89 @@ app.include_router(scenarios_router) app.include_router(powerbi_router) +@app.middleware("http") +async def log_http_requests(request: Request, call_next): + global _requests_served + + timer = RequestTimer() + try: + response = await call_next(request) + except Exception: + log_request( + method=request.method, + path=request.url.path, + status_code=500, + duration_ms=timer.elapsed_ms, + client=request.client.host if request.client else "-", + content_length=None, + ) + raise + + _requests_served += 1 + log_request( + method=request.method, + path=request.url.path, + status_code=response.status_code, + duration_ms=timer.elapsed_ms, + client=request.client.host if request.client else "-", + content_length=response.headers.get("content-length"), + ) + return response + + +@app.middleware("http") +async def enforce_request_limits_and_csrf(request: Request, call_next): + content_length = request.headers.get("content-length") + if content_length: + try: + if int(content_length) > settings.request_body_max_bytes: + return JSONResponse( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + content={"detail": "Request body is too large"}, + ) + except ValueError: + pass + + if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.cookies: + origin = request.headers.get("origin") + if not _origin_is_allowed(origin): + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"detail": "Origin is not allowed"}, + ) + + response = await call_next(request) + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "img-src 'self' data:; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self'; " + "font-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'self'; " + "base-uri 'self'; " + "form-action 'self'" + ) + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + if settings.app_env.lower() == "production": + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response + + +@app.exception_handler(HTTPException) +async def http_exception_handler(_: Request, exc: HTTPException): + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(_: Request, exc: Exception): + fatal("Unhandled server error", exc_info=True, logger_name="data_entry_app.http") + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) + + @app.get("/") def root(): return { @@ -117,9 +313,10 @@ def healthcheck(): if __name__ == "__main__": report = ensure_database_ready() - print(f"Database startup checks complete: {report.summary()}") + success("Database startup checks complete: %s", report.summary(), logger_name="data_entry_app.startup") uvicorn.run( app, - host=os.getenv("HOST", "0.0.0.0"), - port=int(os.getenv("PORT", "8000")), + host=settings.host, + port=settings.port, + access_log=False, ) diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 2796a2d..e86fed3 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -19,6 +19,7 @@ class Product(Base): mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id")) sale_type: Mapped[str] = mapped_column(String(64), default="standard") own_bag: Mapped[bool] = mapped_column(Boolean, default=False) + visible: Mapped[bool] = mapped_column(Boolean, default=True) unit_of_measure: Mapped[str] = mapped_column(String(64), default="20kg bag") items_per_pallet: Mapped[int] = mapped_column(Integer, default=50) bagging_process: Mapped[str | None] = mapped_column(String(64), nullable=True) @@ -31,4 +32,3 @@ class Product(Base): from app.models.mix import Mix # noqa: E402 - diff --git a/backend/app/schemas/client_access.py b/backend/app/schemas/client_access.py index a7cc861..1f63a7b 100644 --- a/backend/app/schemas/client_access.py +++ b/backend/app/schemas/client_access.py @@ -1,30 +1,34 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field class ClientUserCreate(BaseModel): + model_config = ConfigDict(extra="forbid") client_account_id: int - full_name: str - email: str + full_name: str = Field(min_length=1, max_length=255) + email: str = Field(min_length=3, max_length=255) role: str = "viewer" status: str = "invited" is_new_user: bool = True class ClientUserUpdate(BaseModel): - full_name: str | None = None - email: str | None = None + model_config = ConfigDict(extra="forbid") + full_name: str | None = Field(default=None, min_length=1, max_length=255) + email: str | None = Field(default=None, min_length=3, max_length=255) role: str | None = None status: str | None = None is_new_user: bool | None = None class ClientFeatureUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") enabled: bool class ClientUserModulePermissionUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") access_level: str diff --git a/backend/app/schemas/mix.py b/backend/app/schemas/mix.py index 3476064..88065a5 100644 --- a/backend/app/schemas/mix.py +++ b/backend/app/schemas/mix.py @@ -4,14 +4,16 @@ from pydantic import BaseModel, ConfigDict, Field class MixIngredientCreate(BaseModel): + model_config = ConfigDict(extra="forbid") raw_material_id: int quantity_kg: float = Field(gt=0) - notes: str | None = None + notes: str | None = Field(default=None, max_length=1000) class MixIngredientUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") quantity_kg: float | None = Field(default=None, gt=0) - notes: str | None = None + notes: str | None = Field(default=None, max_length=1000) class MixIngredientRead(BaseModel): @@ -26,20 +28,22 @@ class MixIngredientRead(BaseModel): class MixCreate(BaseModel): - client_name: str - name: str + model_config = ConfigDict(extra="forbid") + client_name: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) status: str = "draft" version: int = 1 - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) ingredients: list[MixIngredientCreate] class MixUpdate(BaseModel): - client_name: str | None = None - name: str | None = None + model_config = ConfigDict(extra="forbid") + client_name: str | None = Field(default=None, min_length=1, max_length=255) + name: str | None = Field(default=None, min_length=1, max_length=255) status: str | None = None version: int | None = None - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class MixRead(BaseModel): @@ -57,4 +61,3 @@ class MixRead(BaseModel): mix_cost_per_kg: float | None warnings: list[str] model_config = ConfigDict(from_attributes=True) - diff --git a/backend/app/schemas/mix_calculator.py b/backend/app/schemas/mix_calculator.py index 7817595..98886fd 100644 --- a/backend/app/schemas/mix_calculator.py +++ b/backend/app/schemas/mix_calculator.py @@ -30,13 +30,14 @@ class MixCalculatorSessionLineRead(BaseModel): class MixCalculatorSessionBase(BaseModel): + model_config = ConfigDict(extra="forbid") mix_date: date - client_name: str + client_name: str = Field(min_length=1, max_length=255) product_id: int batch_size_kg: float = Field(gt=0) prepared_by_name: str = Field(min_length=1, max_length=255) status: str = "saved" - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class MixCalculatorSessionCreate(MixCalculatorSessionBase): @@ -44,13 +45,14 @@ class MixCalculatorSessionCreate(MixCalculatorSessionBase): class MixCalculatorSessionUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") mix_date: date | None = None - client_name: str | None = None + client_name: str | None = Field(default=None, min_length=1, max_length=255) product_id: int | None = None batch_size_kg: float | None = Field(default=None, gt=0) prepared_by_name: str | None = Field(default=None, min_length=1, max_length=255) status: str | None = None - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class MixCalculatorPreviewRead(BaseModel): diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index b273b16..2ca8c09 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -4,33 +4,37 @@ from pydantic import BaseModel, ConfigDict, Field class ProductCreate(BaseModel): - client_name: str - item_id: str | None = None - name: str + model_config = ConfigDict(extra="forbid") + client_name: str = Field(min_length=1, max_length=255) + item_id: str | None = Field(default=None, max_length=128) + name: str = Field(min_length=1, max_length=255) mix_id: int sale_type: str = "standard" own_bag: bool = False - unit_of_measure: str = "20kg bag" + visible: bool = True + unit_of_measure: str = Field(default="20kg bag", min_length=1, max_length=64) items_per_pallet: int = Field(default=50, gt=0) - bagging_process: str | None = None + bagging_process: str | None = Field(default=None, max_length=128) distributor_margin: float | None = Field(default=None, gt=0, lt=1) wholesale_margin: float | None = Field(default=None, gt=0, lt=1) - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class ProductUpdate(BaseModel): - client_name: str | None = None - item_id: str | None = None - name: str | None = None + model_config = ConfigDict(extra="forbid") + client_name: str | None = Field(default=None, min_length=1, max_length=255) + item_id: str | None = Field(default=None, max_length=128) + name: str | None = Field(default=None, min_length=1, max_length=255) mix_id: int | None = None sale_type: str | None = None own_bag: bool | None = None - unit_of_measure: str | None = None + visible: bool | None = None + unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64) items_per_pallet: int | None = Field(default=None, gt=0) - bagging_process: str | None = None + bagging_process: str | None = Field(default=None, max_length=128) distributor_margin: float | None = Field(default=None, gt=0, lt=1) wholesale_margin: float | None = Field(default=None, gt=0, lt=1) - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class ProductRead(BaseModel): @@ -43,6 +47,7 @@ class ProductRead(BaseModel): mix_name: str sale_type: str own_bag: bool + visible: bool unit_of_measure: str items_per_pallet: int bagging_process: str | None diff --git a/backend/app/schemas/raw_material.py b/backend/app/schemas/raw_material.py index d05d7a0..ec5eec1 100644 --- a/backend/app/schemas/raw_material.py +++ b/backend/app/schemas/raw_material.py @@ -4,11 +4,12 @@ from pydantic import BaseModel, ConfigDict, Field class RawMaterialPriceVersionCreate(BaseModel): + model_config = ConfigDict(extra="forbid") market_value: float = Field(gt=0) waste_percentage: float = Field(ge=0, default=0.0) effective_date: date status: str = "active" - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate): @@ -21,21 +22,23 @@ class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate): class RawMaterialCreate(BaseModel): - name: str - supplier: str | None = None - unit_of_measure: str + model_config = ConfigDict(extra="forbid") + name: str = Field(min_length=1, max_length=255) + supplier: str | None = Field(default=None, max_length=255) + unit_of_measure: str = Field(min_length=1, max_length=64) kg_per_unit: float = Field(gt=0) status: str = "active" - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) initial_price: RawMaterialPriceVersionCreate class RawMaterialUpdate(BaseModel): - supplier: str | None = None - unit_of_measure: str | None = None + model_config = ConfigDict(extra="forbid") + supplier: str | None = Field(default=None, max_length=255) + unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64) kg_per_unit: float | None = Field(default=None, gt=0) status: str | None = None - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class RawMaterialRead(BaseModel): @@ -50,4 +53,3 @@ class RawMaterialRead(BaseModel): created_at: datetime current_price: RawMaterialPriceVersionRead | None model_config = ConfigDict(from_attributes=True) - diff --git a/backend/app/schemas/scenario.py b/backend/app/schemas/scenario.py index bf758fa..fa8f668 100644 --- a/backend/app/schemas/scenario.py +++ b/backend/app/schemas/scenario.py @@ -6,8 +6,9 @@ from app.schemas.product import ProductCostBreakdown class ScenarioCreate(BaseModel): - name: str - description: str | None = None + model_config = ConfigDict(extra="forbid") + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=2000) overrides: dict = Field(default_factory=dict) diff --git a/backend/app/seed.py b/backend/app/seed.py index 1dbf349..411ec9f 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -25,6 +25,17 @@ WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1) WORKBOOK_SENTINEL_ITEM_ID = "404266" WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx" logger = logging.getLogger("data_entry_app.seed") +HIDDEN_PRODUCT_CLIENTS = frozenset( + { + "Bird Grits", + "Chaff", + "Hay & Straw", + "Hunter Premium Produce", + "Straight Grain", + "Uncategorized", + "Uncategorised", + } +) def _workbook_candidates() -> list[Path]: @@ -287,6 +298,7 @@ def _read_product_rows(workbook) -> list[dict]: "wholesale_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[20]), "process_label": _text(row[8]), "sheet_own_bag": _text(row[5]), + "visible": (_text(row[0]) or "General") not in HIDDEN_PRODUCT_CLIENTS, } ) @@ -569,6 +581,7 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str], mix_id=mix.id, sale_type=row["sale_type"], own_bag=row["own_bag"], + visible=row["visible"], unit_of_measure=row["unit_of_measure"], items_per_pallet=row["items_per_pallet"], bagging_process=row["bagging_process"], @@ -584,6 +597,7 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str], product.mix_id = mix.id product.sale_type = row["sale_type"] product.own_bag = row["own_bag"] + product.visible = row["visible"] product.unit_of_measure = row["unit_of_measure"] product.items_per_pallet = row["items_per_pallet"] product.bagging_process = row["bagging_process"] diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py index 05c9492..463e265 100644 --- a/backend/app/services/mix_calculator_service.py +++ b/backend/app/services/mix_calculator_service.py @@ -27,7 +27,7 @@ def _build_session_access_query(session: AuthSession): def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None: return db.scalar( select(Product) - .where(Product.id == product_id, Product.tenant_id == tenant_id) + .where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True)) .options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material)) ) @@ -122,7 +122,7 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: products = db.scalars( select(Product) - .where(Product.tenant_id == tenant_id) + .where(Product.tenant_id == tenant_id, Product.visible.is_(True)) .options(joinedload(Product.mix)) .order_by(Product.client_name, Product.name) ).all() @@ -191,11 +191,12 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_ } -def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession) -> list[dict]: +def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession, limit: int = 100) -> list[dict]: sessions = db.scalars( _build_session_access_query(auth_session) .options(selectinload(MixCalculatorSession.lines)) .order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc()) + .limit(limit) ).all() return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3940c5b..1285c3b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi>=0.115,<1.0", "openpyxl>=3.1,<4.0", + "rich>=13.9,<15.0", "uvicorn[standard]>=0.30,<1.0", "sqlalchemy>=2.0,<3.0", "pydantic>=2.8,<3.0", diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index abbb8e9..2aa9617 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.core.config import settings -from app.db.migrations import bootstrap_schema, sync_tenant_ids +from app.db.migrations import bootstrap_schema, sync_product_visibility, sync_tenant_ids from app.db.session import Base from app.main import app from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule @@ -17,7 +17,7 @@ from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost, serialize_raw_material -from app.services.mix_calculator_service import calculate_mix_calculator_preview +from app.services.mix_calculator_service import build_mix_calculator_options, calculate_mix_calculator_preview def build_session() -> Session: @@ -151,6 +151,94 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags(): assert "not a whole-bag quantity" in preview["warnings"][0] +def test_mix_calculator_options_hide_invisible_products_and_clients(): + db = build_session() + + maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active") + db.add(maize) + db.flush() + + visible_mix = Mix(tenant_id="hunter-premium-produce", client_name="Peckish", name="Visible Mix", status="active", version=1) + hidden_mix = Mix(tenant_id="hunter-premium-produce", client_name="Chaff", name="Hidden Mix", status="active", version=1) + db.add_all([visible_mix, hidden_mix]) + db.flush() + + db.add_all( + [ + MixIngredient(tenant_id="hunter-premium-produce", mix_id=visible_mix.id, raw_material_id=maize.id, quantity_kg=20), + MixIngredient(tenant_id="hunter-premium-produce", mix_id=hidden_mix.id, raw_material_id=maize.id, quantity_kg=20), + ] + ) + db.flush() + + db.add_all( + [ + Product( + tenant_id="hunter-premium-produce", + client_name="Peckish", + name="Visible Product", + mix_id=visible_mix.id, + visible=True, + sale_type="standard", + own_bag=False, + unit_of_measure="20kg bag", + items_per_pallet=50, + ), + Product( + tenant_id="hunter-premium-produce", + client_name="Chaff", + name="Hidden Product", + mix_id=hidden_mix.id, + visible=False, + sale_type="standard", + own_bag=False, + unit_of_measure="20kg bag", + items_per_pallet=50, + ), + ] + ) + db.commit() + + options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce") + + assert options["clients"] == ["Peckish"] + assert [product["product_name"] for product in options["products"]] == ["Visible Product"] + + +def test_sync_product_visibility_hides_configured_clients(): + engine = create_engine("sqlite:///:memory:") + with engine.begin() as connection: + connection.execute( + text( + """ + CREATE TABLE products ( + id INTEGER PRIMARY KEY, + client_name VARCHAR(255), + visible BOOLEAN NOT NULL DEFAULT TRUE + ) + """ + ) + ) + connection.execute( + text( + """ + INSERT INTO products (id, client_name, visible) + VALUES + (1, 'Chaff', TRUE), + (2, 'Peckish', TRUE), + (3, 'Uncategorized', TRUE) + """ + ) + ) + + updated = sync_product_visibility(engine) + + assert updated == 2 + with engine.connect() as connection: + rows = connection.execute(text("SELECT client_name, visible FROM products ORDER BY id")).all() + assert rows == [("Chaff", 0), ("Peckish", 1), ("Uncategorized", 0)] + + def test_root_and_login_endpoints(): with TestClient(app) as client: root_response = client.get("/") @@ -260,16 +348,15 @@ def test_client_access_endpoints(): "/api/auth/admin/login", json={"email": settings.admin_email, "password": settings.admin_password}, ) - token = login_response.json()["token"] - headers = {"Authorization": f"Bearer {token}"} + admin_cookies = {settings.admin_session_cookie_name: login_response.cookies.get(settings.admin_session_cookie_name)} - access_response = client.get("/api/client-access", headers=headers) + access_response = client.get("/api/client-access", cookies=admin_cookies) assert access_response.status_code == 200 assert len(access_response.json()) >= 1 assert "audit_history" in access_response.json()[0] assert "module_permissions" in access_response.json()[0]["users"][0] - export_response = client.get("/api/powerbi/client-access", headers=headers) + export_response = client.get("/api/powerbi/client-access", cookies=admin_cookies) assert export_response.status_code == 200 assert "client_rows" in export_response.json() assert "permission_rows" in export_response.json() @@ -278,8 +365,8 @@ def test_client_access_endpoints(): "/api/auth/client/login", json={"email": settings.client_email, "password": settings.client_password}, ) - client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"} - superadmin_access_response = client.get("/api/client-access", headers=client_headers) + client_cookies = {settings.session_cookie_name: client_login_response.cookies.get(settings.session_cookie_name)} + superadmin_access_response = client.get("/api/client-access", cookies=client_cookies) assert superadmin_access_response.status_code == 200 assert len(superadmin_access_response.json()) == 1 @@ -291,9 +378,9 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): json={"email": settings.client_email, "password": settings.client_password}, ) assert superadmin_login.status_code == 200 - superadmin_headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"} + superadmin_cookies = {settings.session_cookie_name: superadmin_login.cookies.get(settings.session_cookie_name)} - options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers) + options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies) assert options_response.status_code == 200 options_payload = options_response.json() assert len(options_payload["products"]) >= 100 @@ -310,7 +397,7 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): "prepared_by_name": "Amelia Hart", "notes": "Morning production run", }, - headers=superadmin_headers, + cookies=superadmin_cookies, ) assert create_response.status_code == 201 created = create_response.json() @@ -323,7 +410,7 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): patch_response = client.patch( f"/api/mix-calculator/{created['id']}", json={"batch_size_kg": 550}, - headers=superadmin_headers, + cookies=superadmin_cookies, ) assert patch_response.status_code == 200 assert patch_response.json()["total_bags"] == 27.5 @@ -334,13 +421,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): json={"email": "ethan.cole@hunterpremiumproduce.example", "password": settings.client_password}, ) assert operator_login.status_code == 200 - operator_headers = {"Authorization": f"Bearer {operator_login.json()['token']}"} + operator_cookies = {settings.session_cookie_name: operator_login.cookies.get(settings.session_cookie_name)} - operator_list_response = client.get("/api/mix-calculator", headers=operator_headers) + operator_list_response = client.get("/api/mix-calculator", cookies=operator_cookies) assert operator_list_response.status_code == 200 assert operator_list_response.json() == [] - operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", headers=operator_headers) + operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", cookies=operator_cookies) assert operator_detail_response.status_code == 404 @@ -350,9 +437,9 @@ def test_mix_calculator_pdf_endpoint_returns_pdf(): "/api/auth/client/login", json={"email": settings.client_email, "password": settings.client_password}, ) - headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"} + superadmin_cookies = {settings.session_cookie_name: superadmin_login.cookies.get(settings.session_cookie_name)} - options_response = client.get("/api/mix-calculator/options", headers=headers) + options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies) seeded_product = next( product for product in options_response.json()["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg" ) @@ -367,11 +454,11 @@ def test_mix_calculator_pdf_endpoint_returns_pdf(): "prepared_by_name": "Amelia Hart", "notes": "Morning production run", }, - headers=headers, + cookies=superadmin_cookies, ) created = create_response.json() - pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", headers=headers) + pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", cookies=superadmin_cookies) assert pdf_response.status_code == 200 assert pdf_response.headers["content-type"] == "application/pdf" @@ -385,8 +472,8 @@ def test_module_permission_blocks_client_module_access(): "/api/auth/admin/login", json={"email": settings.admin_email, "password": settings.admin_password}, ) - admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"} - access_response = client.get("/api/client-access", headers=admin_headers) + admin_cookies = {settings.admin_session_cookie_name: admin_login_response.cookies.get(settings.admin_session_cookie_name)} + access_response = client.get("/api/client-access", cookies=admin_cookies) first_client = access_response.json()[0] first_user = next(user for user in first_client["users"] if user["email"] == settings.client_email) @@ -396,15 +483,15 @@ def test_module_permission_blocks_client_module_access(): client.patch( f"/api/client-access/users/{first_user['id']}/module-permissions/{permission['module_key']}", json={"access_level": "none"}, - headers=admin_headers, + cookies=admin_cookies, ) client_login_response = client.post( "/api/auth/client/login", json={"email": settings.client_email, "password": settings.client_password}, ) - client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"} - raw_materials_response = client.get("/api/raw-materials", headers=client_headers) + client_cookies = {settings.session_cookie_name: client_login_response.cookies.get(settings.session_cookie_name)} + raw_materials_response = client.get("/api/raw-materials", cookies=client_cookies) assert raw_materials_response.status_code == 403 diff --git a/deploy/nginx/clients.lean-101.conf b/deploy/nginx/clients.lean-101.conf index 621b793..6dccbcf 100644 --- a/deploy/nginx/clients.lean-101.conf +++ b/deploy/nginx/clients.lean-101.conf @@ -26,7 +26,8 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header X-XSS-Protection "1; mode=block" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always; location /_app/immutable/ { expires 1y; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index c7a3adc..04b41a3 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -24,6 +24,7 @@ services: restart: unless-stopped environment: APP_NAME: ${APP_NAME:-Lean 101 Clients API} + APP_ENV: ${APP_ENV:-production} DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com} @@ -34,6 +35,16 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required} CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au} + CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-} + TRUSTED_HOSTS: ${TRUSTED_HOSTS:-clients.lean-101.com.au} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} + SESSION_COOKIE_SAMESITE: ${SESSION_COOKIE_SAMESITE:-lax} + SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-} + SESSION_TTL_SECONDS: ${SESSION_TTL_SECONDS:-43200} + REQUEST_BODY_MAX_BYTES: ${REQUEST_BODY_MAX_BYTES:-1048576} + LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8} + LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300} + DOCS_ENABLED: ${DOCS_ENABLED:-false} depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index d8019b8..8c3693d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: restart: unless-stopped environment: APP_NAME: ${APP_NAME:-Lean 101 Clients API} + APP_ENV: ${APP_ENV:-development} DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com} @@ -17,6 +18,16 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-lean101-admin} AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production} CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au} + CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-} + TRUSTED_HOSTS: ${TRUSTED_HOSTS:-localhost,127.0.0.1} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + SESSION_COOKIE_SAMESITE: ${SESSION_COOKIE_SAMESITE:-lax} + SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-} + SESSION_TTL_SECONDS: ${SESSION_TTL_SECONDS:-43200} + REQUEST_BODY_MAX_BYTES: ${REQUEST_BODY_MAX_BYTES:-1048576} + LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8} + LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300} + DOCS_ENABLED: ${DOCS_ENABLED:-true} volumes: - clients_app_data:/data healthcheck: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index af6ced8..7810c50 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -14,10 +14,18 @@ ENV NODE_ENV=production WORKDIR /app +RUN addgroup --system app && adduser --system --ingroup app app + COPY --from=builder /app/build ./build COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/node_modules ./node_modules +RUN chown -R app:app /app + +USER app + EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 CMD node -e "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD ["node", "build"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 879fdef..b01362e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { - "name": "data-entry-app-frontend", - "version": "0.1.5", + "name": "hunter-app", + "version": "1.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "data-entry-app-frontend", - "version": "0.1.5", + "name": "hunter-app", + "version": "1.5.6", "dependencies": { "lucide-svelte": "^1.0.1" }, @@ -15,7 +15,7 @@ "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/kit": "^2.7.1", "svelte": "^5.0.0", - "typescript": "^5.5.4", + "typescript": "^5.9.3", "vite": "^8.0.0", "vitest": "^4.0.0" } diff --git a/frontend/package.json b/frontend/package.json index 91989cf..af3619b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "data-entry-app-frontend", + "name": "hunter-app", "version": "1.5.6", "private": true, "type": "module", @@ -14,7 +14,7 @@ "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/kit": "^2.7.1", "svelte": "^5.0.0", - "typescript": "^5.5.4", + "typescript": "^5.9.3", "vite": "^8.0.0", "vitest": "^4.0.0" }, diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index f474da7..db03f60 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -54,7 +54,7 @@ describe('api fetch injection', () => { await expect(call(injectedFetch)).resolves.toEqual(body); expect(injectedFetch).toHaveBeenCalledTimes(1); - expect(injectedFetch.mock.calls[0]?.[0]).toBe(`http://127.0.0.1:8000${path}`); + expect(injectedFetch.mock.calls[0]?.[0]).toBe(path); expect(globalFetch).not.toHaveBeenCalled(); }); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 411cc53..f931a36 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -37,7 +37,6 @@ import type { } from '$lib/types'; import { getStoredAdminSession, getStoredClientSession } from '$lib/session'; -const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000'; const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.'; type AuthMode = 'none' | 'client' | 'admin' | 'manager'; @@ -51,40 +50,62 @@ function getApiBaseUrl() { } } - const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim(); - if (configuredBaseUrl) { - return configuredBaseUrl.replace(/\/+$/, ''); - } - if (browser) { - return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`; + const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim(); + if (configuredBaseUrl) { + try { + const configuredUrl = new URL(configuredBaseUrl, window.location.origin); + // Keep browser API traffic same-origin by default. This avoids CORS, + // CSP `connect-src`, and cookie policy failures when the backend is + // reverse-proxied under `/api` on the same host. + if (configuredUrl.origin === window.location.origin || configuredUrl.hostname === window.location.hostname) { + return ''; + } + return configuredUrl.toString().replace(/\/+$/, ''); + } catch { + return ''; + } + } + + return ''; } - return `http://127.0.0.1:${DEFAULT_API_PORT}`; + const defaultApiPort = env.PUBLIC_API_PORT || '8000'; + return `http://127.0.0.1:${defaultApiPort}`; } function buildApiUrl(path: string) { return `${getApiBaseUrl()}${path}`; } -function getToken(auth: AuthMode) { - if (!browser) { - return null; - } - +function getSessionFingerprint(auth: AuthMode) { if (auth === 'client') { - return getStoredClientSession()?.token ?? null; + const session = getStoredClientSession(); + return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : ''; } if (auth === 'admin') { - return getStoredAdminSession()?.token ?? null; + const session = getStoredAdminSession(); + return session ? `${session.role}:${session.email}` : ''; } if (auth === 'manager') { - return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null; + const admin = getStoredAdminSession(); + if (admin) { + return `${admin.role}:${admin.email}`; + } + const client = getStoredClientSession(); + return client ? `${client.role}:${client.email}:${client.user_id ?? ''}` : ''; } - return null; + return ''; +} + +function resolveRequestUrl(path: string, fetcher: ApiFetch) { + if (fetcher !== fetch) { + return path; + } + return buildApiUrl(path); } function normalizeRequestError(error: unknown) { @@ -107,9 +128,8 @@ function normalizeRequestError(error: unknown) { async function fetchJson(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise { try { - const token = getToken(auth); - const response = await fetcher(buildApiUrl(path), { - headers: token ? { Authorization: `Bearer ${token}` } : undefined + const response = await fetcher(resolveRequestUrl(path, fetcher), { + credentials: 'include' }); if (!response.ok) { if (auth !== 'none') { @@ -136,8 +156,8 @@ const inflightRequests = new Map>(); const READ_CACHE_TTL_MS = 30_000; function makeCacheKey(path: string, auth: AuthMode) { - const token = browser ? getToken(auth) ?? '' : ''; - return `${auth}:${token.slice(-8)}:${path}`; + const sessionFingerprint = browser ? getSessionFingerprint(auth) : ''; + return `${auth}:${sessionFingerprint}:${path}`; } async function cachedFetchJson( @@ -189,13 +209,12 @@ async function request( fetcher: ApiFetch = fetch ): Promise { try { - const token = getToken(auth); - const response = await fetcher(buildApiUrl(path), { + const response = await fetcher(resolveRequestUrl(path, fetcher), { headers: { 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.headers ?? {}) }, + credentials: 'include', ...options }); @@ -218,6 +237,9 @@ async function request( // after the user creates or updates anything. clearApiCache(); } + if (response.status === 204) { + return undefined as T; + } return (await response.json()) as T; } catch (error) { throw normalizeRequestError(error); @@ -230,9 +252,8 @@ async function requestBlob( fetcher: ApiFetch = fetch ): Promise { try { - const token = getToken(auth); - const response = await fetcher(buildApiUrl(path), { - headers: token ? { Authorization: `Bearer ${token}` } : undefined + const response = await fetcher(resolveRequestUrl(path, fetcher), { + credentials: 'include' }); if (!response.ok) { @@ -326,6 +347,9 @@ export const api = { }), clientSession: (fetcher?: ApiFetch) => request('/api/auth/client/session', { method: 'GET' }, 'client', fetcher), adminSession: (fetcher?: ApiFetch) => request('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher), + clientLogout: () => request('/api/auth/client/logout', { method: 'POST' }, 'client'), + adminLogout: () => request('/api/auth/admin/logout', { method: 'POST' }, 'admin'), + internalLogout: () => request('/api/access/logout', { method: 'POST' }, 'client'), login: (email: string, password: string) => request('/api/auth/client/login', { method: 'POST', diff --git a/frontend/src/lib/components/AdminShell.svelte b/frontend/src/lib/components/AdminShell.svelte index 549eb8f..a5081fd 100644 --- a/frontend/src/lib/components/AdminShell.svelte +++ b/frontend/src/lib/components/AdminShell.svelte @@ -1,6 +1,7 @@ + +{#if blocked} +
+

{label}

+

{title}

+

{detail}

+
+{:else} + {@render children()} +{/if} + + diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index bbb183a..c90d45c 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -1,5 +1,6 @@ - {pageTitle(page.url.pathname)} | Hunter Premium Produce + {shellTitle} | Hunter Premium Produce {#if !$clientSession} @@ -314,77 +386,98 @@ {#if !showBottomNav} clientSession.clear()} + onSignOut={signOut} /> {/if}
openPalette()} + {canUseWorkspaceSearch} + {canOpenSettings} + onOpenPalette={() => canUseWorkspaceSearch && openPalette()} onToggleUserMenu={() => { userMenuOpen = !userMenuOpen; quickMenuOpen = false; }} onOpenSettings={openSettings} - onSignOut={() => clientSession.clear()} + onSignOut={signOut} />
- {#if !isRootRoute && isRestoringSession} -
-

Checking Session

-

Restoring your client workspace.

-

Refreshing the current page with the saved browser session before deciding whether sign-in is required.

-
- {:else} + {@render children()} - {/if} +
{#if quickMenuOpen} {/if} - + {#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch} + + {/if}
@@ -405,12 +498,11 @@ {#if navOpen} - + {/if} {/if} @@ -835,15 +937,10 @@ background: var(--line); } - .nav-sublist a { + .drawer-sublist a { position: relative; } - .bottom-nav-icon svg { - width: 0.9rem; - height: 0.9rem; - } - /* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */ .nav-icon.muted { color: #fff; @@ -1002,16 +1099,6 @@ color: var(--muted); } - .locked-card a { - display: inline-flex; - margin-top: 1rem; - padding: 0.78rem 0.92rem; - border-radius: 0.88rem; - background: var(--color-brand); - color: #fff; - font-weight: 600; - } - .palette-overlay { position: fixed; inset: 0; diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte index 4dacdae..442fc0b 100644 --- a/frontend/src/lib/components/Toast.svelte +++ b/frontend/src/lib/components/Toast.svelte @@ -3,7 +3,7 @@ function icon(type: Toast['type']) { if (type === 'success') return '✓'; - if (type === 'error') return '✕'; + if (type === 'error') return '!'; if (type === 'loading') return null; // spinner shown separately return 'ℹ'; } diff --git a/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte b/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte index 0d62014..1621180 100644 --- a/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte +++ b/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte @@ -19,7 +19,7 @@ const todayIso = new Date().toISOString().slice(0, 10); function initialClientNameValue() { - return initialSession?.client_name ?? options.clients[0] ?? ''; + return initialSession?.client_name ?? ''; } function initialProductIdValue() { @@ -54,6 +54,7 @@ let notes = $state(initialNotesValue()); let preview = $state(initialPreviewValue()); let formError = $state(''); + let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock products.'); let previewLoading = $state(false); let saveLoading = $state(false); let previewModalOpen = $state(false); @@ -84,18 +85,7 @@ const selectedProduct = $derived(filteredProducts.find((product) => product.product_id === productId) ?? null); $effect(() => { - if (!clientName && availableClients.length) { - clientName = availableClients[0]; - } - }); - - $effect(() => { - if (filteredProducts.length && !filteredProducts.some((product) => product.product_id === productId)) { - productId = filteredProducts[0].product_id; - return; - } - - if (!filteredProducts.length) { + if (!filteredProducts.some((product) => product.product_id === productId)) { productId = 0; } }); @@ -116,26 +106,32 @@ function buildPayload(): MixCalculatorCreateInput | null { formError = ''; + formHint = ''; const numericBatchSize = Number(batchSizeKg); if (!mixDate) { formError = 'Select a mix date.'; - return null; - } - if (!clientName) { - formError = 'Select a client.'; - return null; - } - if (!productId) { - formError = 'Select a product.'; - return null; - } - if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) { - formError = 'Enter a batch size greater than zero.'; + formHint = 'Choose the production date before calculating the mix.'; return null; } if (!preparedByName.trim()) { formError = 'Enter the prepared by name.'; + formHint = 'Record the operator or staff member responsible for this mix.'; + return null; + } + if (!clientName) { + formError = 'Select a client to unlock matching products.'; + formHint = 'Products stay disabled until a client is selected.'; + return null; + } + if (!productId) { + formError = 'Select a product.'; + formHint = 'Pick one of the products available for the selected client.'; + return null; + } + if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) { + formError = 'Enter a batch size greater than zero.'; + formHint = 'Batch size must be a positive number before the mix can be calculated.'; return null; } @@ -172,7 +168,7 @@ } function clearForm() { - clientName = options.clients[0] ?? ''; + clientName = ''; productId = 0; mixDate = todayIso; batchSizeKg = ''; @@ -180,8 +176,28 @@ notes = ''; preview = null; formError = ''; + formHint = 'Select a mix date and prepared by name, then choose a client to unlock products.'; } + $effect(() => { + if (!clientName) { + formHint = 'Select a client to unlock the product list.'; + return; + } + + if (!filteredProducts.length) { + formHint = `No products are available for ${clientName}.`; + return; + } + + if (!productId) { + formHint = 'Select a product for the chosen client.'; + return; + } + + formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`; + }); + function printPreview() { if (typeof window !== 'undefined') { window.print(); @@ -287,15 +303,24 @@

{formError}

{/if} + {#if !formError && formHint} +

{formHint}

+ {/if} +
+ + -
-
- -
+ {#if canUseWorkspaceSearch} +
+ +
+ {:else} +
+ {/if}
- + {#if canOpenSettings} + + {/if} {#if session} {:else if !sessionHydrated} diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts index 455e2af..ad2fe21 100644 --- a/frontend/src/lib/session.ts +++ b/frontend/src/lib/session.ts @@ -5,7 +5,7 @@ export type AppSession = { name: string; email: string; role: string; - token: string; + token?: string | null; tenant_id?: string | null; client_role?: string | null; user_id?: number | null; @@ -59,15 +59,16 @@ function createSessionStore(storageKey: string) { return { subscribe: store.subscribe, set(session: AppSession) { + const storedSession = { ...session, token: null }; if (browser) { - localStorage.setItem(storageKey, JSON.stringify(session)); + localStorage.setItem(storageKey, JSON.stringify(storedSession)); } - store.set(session); + store.set(storedSession); }, clear() { if (browser) { localStorage.removeItem(storageKey); - // Drop any cached API responses keyed to the old session token. + // Drop any cached API responses keyed to the old session identity. // Imported lazily so this module stays free of api.ts side-effects. import('$lib/api').then(({ clearApiCache }) => clearApiCache()).catch(() => {}); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0a47641..dcaf0b6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -180,6 +180,7 @@ export type Product = { mix_name: string; sale_type: string; own_bag?: boolean; + visible?: boolean; unit_of_measure: string; items_per_pallet?: number; bagging_process?: string | null; @@ -334,7 +335,7 @@ export type LoginResponse = { name: string; email: string; role: string; - token: string; + token?: string | null; tenant_id?: string | null; client_role?: string | null; user_id?: number | null; diff --git a/frontend/src/lib/workspace-access.test.ts b/frontend/src/lib/workspace-access.test.ts new file mode 100644 index 0000000..42597a6 --- /dev/null +++ b/frontend/src/lib/workspace-access.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { canAccessRoute, getDefaultRouteForRole, getWorkspaceRole } from './workspace-access'; + +describe('workspace access policy', () => { + const operationsSession = { + role: 'internal', + role_name: 'Operations', + permissions: ['view_mix_calculator', 'use_mix_calculator', 'save_mix_calculator_session'], + name: 'Ops User', + email: 'ops@example.com', + token: 'token' + }; + + const adminSession = { + role: 'internal', + role_name: 'Admin', + permissions: ['view_dashboard', 'view_mix_calculator', 'use_mix_calculator'], + name: 'Admin User', + email: 'admin@example.com', + token: 'token' + }; + + it('classifies operations users and sends them to mix calculator by default', () => { + expect(getWorkspaceRole(operationsSession)).toBe('operations'); + expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator/new'); + }); + + it('prevents operations users from opening the dashboard route', () => { + expect(canAccessRoute(operationsSession, '/')).toBe(false); + expect(canAccessRoute(operationsSession, '/mix-calculator')).toBe(true); + }); + + it('keeps dashboard access for admins', () => { + expect(getWorkspaceRole(adminSession)).toBe('admin'); + expect(canAccessRoute(adminSession, '/')).toBe(true); + }); +}); diff --git a/frontend/src/lib/workspace-access.ts b/frontend/src/lib/workspace-access.ts new file mode 100644 index 0000000..5ebc680 --- /dev/null +++ b/frontend/src/lib/workspace-access.ts @@ -0,0 +1,194 @@ +import { featureFlags } from '$lib/features'; +import { hasModuleAccess, hasPermission, type AppSession } from '$lib/session'; + +export type WorkspaceRole = 'admin' | 'operations' | 'full' | 'client' | 'unknown'; + +type RouteAccessRule = { + path: string; + roles: WorkspaceRole[]; + matches: (pathname: string) => boolean; +}; + +function hasPathPrefix(pathname: string, prefix: string) { + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +function canAccessWorkspaceArea( + session: AppSession | null | undefined, + moduleKey: string, + permissionKeys: string[], + minimumLevel: 'view' | 'edit' | 'manage' = 'view' +) { + if (!session) { + return false; + } + + if (session.role === 'internal') { + return permissionKeys.some((permissionKey) => hasPermission(session, permissionKey)); + } + + return hasModuleAccess(session, moduleKey, minimumLevel); +} + +export function getWorkspaceRole(session: AppSession | null | undefined): WorkspaceRole { + if (!session) { + return 'unknown'; + } + + if (session.role === 'admin') { + return 'admin'; + } + + if (session.role !== 'internal') { + return 'client'; + } + + if (session.role_name === 'Admin') { + return 'admin'; + } + + if (session.role_name === 'Operations') { + return 'operations'; + } + + if (session.role_name === 'Full Access') { + return 'full'; + } + + return 'unknown'; +} + +export function canOpenDashboard(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'dashboard', ['view_dashboard']); +} + +export function canOpenRawMaterials(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'raw_materials', ['view_raw_materials', 'edit_raw_materials']); +} + +export function canOpenMixMaster(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'mix_master', ['view_mixes', 'edit_mixes']); +} + +export function canCreateMixWorksheet(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'mix_master', ['edit_mixes'], 'edit'); +} + +export function canOpenMixCalculator(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'mix_calculator', ['view_mix_calculator', 'use_mix_calculator']); +} + +export function canCreateMixSession(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'mix_calculator', ['use_mix_calculator', 'save_mix_calculator_session'], 'edit'); +} + +export function canOpenProducts(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']); +} + +export function canOpenScenarios(session: AppSession | null | undefined) { + return !!session && hasModuleAccess(session, 'scenarios'); +} + +export function canOpenReporting(session: AppSession | null | undefined) { + return canOpenProducts(session); +} + +export function canOpenSettings(session: AppSession | null | undefined) { + if (!session) { + return false; + } + + return session.role === 'internal' + ? hasPermission(session, 'view_settings') || hasPermission(session, 'edit_settings') + : true; +} + +export function canOpenClientAccess(session: AppSession | null | undefined) { + return !!session && hasModuleAccess(session, 'client_access', 'manage'); +} + +export const routeAccessRules: RouteAccessRule[] = [ + { path: '/', roles: ['admin', 'full', 'client'], matches: (pathname) => pathname === '/' }, + { + path: '/mix-calculator', + roles: ['admin', 'operations', 'full', 'client'], + matches: (pathname) => hasPathPrefix(pathname, '/mix-calculator') + }, + { + path: '/raw-materials', + roles: ['admin', 'full', 'client'], + matches: (pathname) => hasPathPrefix(pathname, '/raw-materials') + }, + { path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') }, + { path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') }, + { path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') }, + { path: '/scenarios', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/scenarios') }, + { + path: '/settings', + roles: ['admin', 'full', 'operations', 'client'], + matches: (pathname) => hasPathPrefix(pathname, '/settings') + }, + { + path: '/client-access', + roles: ['admin', 'client'], + matches: (pathname) => hasPathPrefix(pathname, '/client-access') + } +]; + +export function getDefaultRouteForRole(session: AppSession | null | undefined) { + const role = getWorkspaceRole(session); + + if (role === 'operations') { + return featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'; + } + + if (role === 'admin' || role === 'full' || role === 'client') { + if (canOpenDashboard(session)) return '/'; + if (canOpenMixCalculator(session)) return featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'; + if (canOpenRawMaterials(session)) return '/raw-materials'; + if (canOpenMixMaster(session)) return '/mixes'; + if (canOpenProducts(session)) return '/products'; + if (canOpenScenarios(session)) return '/scenarios'; + if (canOpenSettings(session)) return '/settings'; + } + + return '/'; +} + +export function canAccessRoute(session: AppSession | null | undefined, pathname: string) { + const rule = routeAccessRules.find((candidate) => candidate.matches(pathname)); + if (!rule) { + return true; + } + + const role = getWorkspaceRole(session); + if (!rule.roles.includes(role)) { + return false; + } + + if (pathname === '/') return canOpenDashboard(session); + if (pathname.startsWith('/mix-calculator')) return canOpenMixCalculator(session); + if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session); + if (pathname.startsWith('/mixes')) return canOpenMixMaster(session); + if (pathname.startsWith('/products')) return canOpenProducts(session); + if (pathname.startsWith('/scenarios')) return canOpenScenarios(session); + if (pathname.startsWith('/reporting')) return canOpenReporting(session); + if (pathname.startsWith('/settings')) return canOpenSettings(session); + if (pathname.startsWith('/client-access')) return canOpenClientAccess(session); + return true; +} + +export function canUseWorkspaceSearch(session: AppSession | null | undefined) { + return ( + canOpenDashboard(session) || + canOpenRawMaterials(session) || + canOpenMixMaster(session) || + canOpenMixCalculator(session) || + canOpenProducts(session) || + canOpenScenarios(session) + ); +} + +export const getWorkspaceHomeHref = getDefaultRouteForRole; +export const isWorkspaceRouteAllowed = canAccessRoute; diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..cce514e --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,244 @@ + + + + {title} | Hunter Premium Produce + + +
+
+ + +
+
+ +
+

Workspace Error

+ Hunter Premium Produce +
+
+ {status} +
+ +
+

Route Response

+

{title}

+

{detail}

+
+ + +
+
+ + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index b5f3dbc..d4f8c65 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,10 +1,13 @@