This commit is contained in:
2026-05-10 09:46:07 +12:00
parent cfc193b713
commit 2f2466ecac
81 changed files with 2571 additions and 413 deletions
+18 -7
View File
@@ -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
+33
View File
@@ -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
+17 -6
View File
@@ -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
+8 -1
View File
@@ -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
+109
View File
@@ -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 <package>@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:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

+25
View File
@@ -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:
+8 -1
View File
@@ -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"]
+30 -6
View File
@@ -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
+44 -9
View File
@@ -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
+3 -2
View File
@@ -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)
+2 -2
View File
@@ -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
+14 -7
View File
@@ -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(
+3 -2
View File
@@ -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)
+7 -3
View File
@@ -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]
+8 -3
View File
@@ -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]
+14 -3
View File
@@ -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:
+7 -3
View File
@@ -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)
+10 -8
View File
@@ -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}",
+69 -1
View File
@@ -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()
+51
View File
@@ -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
+372
View File
@@ -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
+39
View File
@@ -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
+15
View File
@@ -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)
+30 -1
View File
@@ -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)
+210 -13
View File
@@ -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,
)
+1 -1
View File
@@ -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
+9 -5
View File
@@ -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
+12 -9
View File
@@ -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)
+6 -4
View File
@@ -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):
+17 -12
View File
@@ -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
+11 -9
View File
@@ -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)
+3 -2
View File
@@ -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)
+14
View File
@@ -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"]
@@ -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]
+1
View File
@@ -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",
+111 -24
View File
@@ -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
+2 -1
View File
@@ -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;
+11
View File
@@ -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
+11
View File
@@ -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:
+8
View File
@@ -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"]
+5 -5
View File
@@ -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"
}
+2 -2
View File
@@ -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"
},
+1 -1
View File
@@ -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();
});
+49 -25
View File
@@ -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() {
}
}
if (browser) {
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
return configuredBaseUrl.replace(/\/+$/, '');
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 '';
}
}
if (browser) {
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
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<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
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<string, Promise<unknown>>();
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<T>(
@@ -189,13 +209,12 @@ async function request<T>(
fetcher: ApiFetch = fetch
): Promise<T> {
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<T>(
// 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<Blob> {
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<LoginResponse>('/api/auth/client/session', { method: 'GET' }, 'client', fetcher),
adminSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher),
clientLogout: () => request<void>('/api/auth/client/logout', { method: 'POST' }, 'client'),
adminLogout: () => request<void>('/api/auth/admin/logout', { method: 'POST' }, 'admin'),
internalLogout: () => request<void>('/api/access/logout', { method: 'POST' }, 'client'),
login: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
+19 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { api } from '$lib/api';
import { adminSession, sessionHydrated } from '$lib/session';
const navigation = [
@@ -10,7 +11,7 @@
let { children } = $props();
let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null);
let restoredSessionKey = $state<string | null>(null);
function matchesRoute(href: string, pathname: string) {
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
@@ -29,31 +30,41 @@
.toUpperCase();
}
async function signOut() {
try {
await api.adminLogout();
} catch {
// Clearing the local session remains the safe fallback.
} finally {
adminSession.clear();
}
}
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
$effect(() => {
const hydrated = $sessionHydrated;
const token = $adminSession?.token ?? null;
const sessionKey = $adminSession ? `${$adminSession.role}:${$adminSession.email}` : null;
if (!hydrated) {
return;
}
if (!token) {
if (!sessionKey) {
isRestoringSession = false;
restoredToken = null;
restoredSessionKey = null;
return;
}
if (restoredToken === token) {
if (restoredSessionKey === sessionKey) {
return;
}
restoredToken = token;
restoredSessionKey = sessionKey;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredToken === token) {
if (restoredSessionKey === sessionKey) {
isRestoringSession = false;
}
});
@@ -87,7 +98,7 @@
<div class="admin-footer">
<a href="/">Open client workspace</a>
{#if $adminSession}
<button type="button" onclick={() => adminSession.clear()}>Sign out</button>
<button type="button" onclick={signOut}>Sign out</button>
{/if}
</div>
</aside>
@@ -0,0 +1,51 @@
<script lang="ts">
let {
blocked = false,
label = 'Checking Access',
title = 'Preparing your workspace.',
detail = 'Applying your access rules before rendering this page.',
children
} = $props();
</script>
{#if blocked}
<section class="auth-gate-card">
<p class="auth-gate-label">{label}</p>
<h2>{title}</h2>
<p>{detail}</p>
</section>
{:else}
{@render children()}
{/if}
<style>
.auth-gate-card {
display: grid;
gap: 0.5rem;
padding: 1.35rem 1.4rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
}
.auth-gate-label {
margin: 0;
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.auth-gate-card h2 {
margin: 0;
font-size: 1.18rem;
}
.auth-gate-card p:last-child {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.55;
}
</style>
+164 -77
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import AuthGate from '$lib/components/AuthGate.svelte';
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
@@ -8,6 +9,23 @@
import { page } from '$app/state';
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { featureFlags } from '$lib/features';
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenProducts as sessionCanOpenProducts,
canOpenRawMaterials as sessionCanOpenRawMaterials,
canOpenReporting as sessionCanOpenReporting,
canOpenScenarios as sessionCanOpenScenarios,
canOpenSettings as sessionCanOpenSettings,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
getWorkspaceRole,
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
accessControlItem,
baseSearchItems,
@@ -44,34 +62,49 @@
let navOpen = $state(false);
let showBottomNav = $state(false);
let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null);
let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchToken = $state<string | null>(null);
let seededSearchKey = $state<string | null>(null);
let paletteInput: HTMLInputElement | null = $state(null);
const appVersion = `v${packageInfo.version}`;
const releaseStage = 'Beta';
const currentYear = new Date().getFullYear();
const visibleDashboardItem = $derived(
!$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null
);
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenRawMaterials = $derived(sessionCanOpenRawMaterials($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenProducts = $derived(sessionCanOpenProducts($clientSession));
const canOpenScenarios = $derived(sessionCanOpenScenarios($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
const shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname));
const shellBreadcrumbs = $derived(routeGuardPending ? clientBreadcrumbs(workspaceHomeHref) : clientBreadcrumbs(page.url.pathname));
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey))
);
const visibleMixCalculatorItem = $derived(
!$clientSession || !mixCalculatorItem.moduleKey || hasModuleAccess($clientSession, mixCalculatorItem.moduleKey)
? mixCalculatorItem
: null
);
const visibleReportingItem = $derived(
!$clientSession || !reportingItem.moduleKey || hasModuleAccess($clientSession, reportingItem.moduleKey)
? reportingItem
: null
: workingDocumentItems.filter((item) => {
if (item.href === '/raw-materials') return canOpenRawMaterials;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/products') return canOpenProducts;
if (item.href === '/scenarios') return canOpenScenarios;
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
...(!isOperationsUser ? footerLinks : []),
...(!canOpenClientAccess
? []
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
] as FooterLink[]);
@@ -85,7 +118,22 @@
const workingDocumentsActive = $derived(
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
);
const searchItems = $derived([...baseSearchItems, ...seededSearchItems]);
const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard;
if (item.href === '/raw-materials') return canOpenRawMaterials;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/mix-calculator/new') return canCreateMixSession;
if (item.href === '/products') return canOpenProducts;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
if (item.href === '/scenarios') return canOpenScenarios;
return true;
})
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
function openPalette(query = '') {
paletteQuery = query;
@@ -116,6 +164,20 @@
await goto('/settings');
}
async function signOut() {
try {
if ($clientSession?.role === 'internal') {
await api.internalLogout();
} else {
await api.clientLogout();
}
} catch {
// Clearing the local session remains the safe fallback.
} finally {
clientSession.clear();
}
}
const filteredSearchItems = $derived(
searchItems.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
@@ -140,23 +202,23 @@
$effect(() => {
const hydrated = $sessionHydrated;
const token = $clientSession?.token ?? null;
const sessionKey = $clientSession ? `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}` : null;
if (!hydrated) {
return;
}
if (!token) {
if (!sessionKey) {
isRestoringSession = false;
restoredToken = null;
restoredSessionKey = null;
return;
}
if (restoredToken === token) {
if (restoredSessionKey === sessionKey) {
return;
}
restoredToken = token;
restoredSessionKey = sessionKey;
isRestoringSession = true;
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
@@ -165,14 +227,12 @@
refresh
.then((session) => {
// /api/access/me does not re-issue a token; preserve the existing one.
const nextToken = session.token ?? token;
restoredToken = nextToken;
clientSession.set({ ...session, token: nextToken });
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredToken = null;
restoredSessionKey = null;
clientSession.clear();
})
.finally(() => {
@@ -186,30 +246,30 @@
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const token = session?.token ?? null;
const sessionKey = session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : null;
const shouldSeed = paletteOpen;
if (!hydrated || !session || !token) {
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
seededSearchToken = null;
seededSearchKey = null;
return;
}
if (!shouldSeed || seededSearchToken === token) {
if (!shouldSeed || seededSearchKey === sessionKey) {
return;
}
seededSearchToken = token;
seededSearchKey = sessionKey;
Promise.all([
hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && hasModuleAccess(session, 'mix_calculator')
sessionCanOpenProducts(session) ? api.products() : Promise.resolve([]),
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions()
: Promise.resolve([])
])
.then(([products, mixes, sessions]) => {
if (seededSearchToken !== token) {
if (seededSearchKey !== sessionKey) {
return;
}
@@ -235,7 +295,7 @@
];
})
.catch(() => {
if (seededSearchToken === token) {
if (seededSearchKey === sessionKey) {
seededSearchItems = [];
}
});
@@ -247,6 +307,18 @@
}
});
$effect(() => {
if (!$sessionHydrated || !$clientSession) {
return;
}
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
return;
}
goto(workspaceHomeHref, { replaceState: true });
});
onMount(() => {
syncViewport();
@@ -258,7 +330,7 @@
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/')) {
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
openPalette();
}
@@ -291,7 +363,7 @@
</script>
<svelte:head>
<title>{pageTitle(page.url.pathname)} | Hunter Premium Produce</title>
<title>{shellTitle} | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
@@ -314,64 +386,84 @@
{#if !showBottomNav}
<ClientPrimaryRail
currentPath={page.url.pathname}
currentPath={shellPathname}
primaryItems={[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleReportingItem ? [visibleReportingItem] : [])
]}
brandHref={workspaceHomeHref}
workingDocumentItems={visibleWorkingDocumentItems}
footerItems={visibleFooterLinks}
{appVersion}
{releaseStage}
{currentYear}
{canOpenSettings}
onOpenSettings={openSettings}
onSignOut={() => clientSession.clear()}
onSignOut={signOut}
/>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
breadcrumbs={clientBreadcrumbs(page.url.pathname)}
title={pageTitle(page.url.pathname)}
breadcrumbs={shellBreadcrumbs}
title={shellTitle}
sessionHydrated={$sessionHydrated}
session={$clientSession}
{userInitials}
{userMenuOpen}
onOpenPalette={() => openPalette()}
{canUseWorkspaceSearch}
{canOpenSettings}
onOpenPalette={() => canUseWorkspaceSearch && openPalette()}
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
quickMenuOpen = false;
}}
onOpenSettings={openSettings}
onSignOut={() => clientSession.clear()}
onSignOut={signOut}
/>
<main class="content">
{#if !isRootRoute && isRestoringSession}
<section class="locked-card loading-card">
<p class="workspace-label">Checking Session</p>
<h2>Restoring your client workspace.</h2>
<p>Refreshing the current page with the saved browser session before deciding whether sign-in is required.</p>
</section>
{:else}
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
detail={
isRestoringSession
? 'Refreshing the saved session before rendering workspace content.'
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
}
>
{@render children()}
{/if}
</AuthGate>
</main>
</div>
<div class="quick-fab-wrap">
{#if quickMenuOpen}
<div class="menu-panel quick-fab-panel">
{#if canOpenMixMaster}
<a href="/mixes">Open mix costing</a>
{/if}
{#if canCreateMixWorksheet}
<a href="/mixes/new">Create mix worksheet</a>
{/if}
{#if canOpenMixCalculator}
<a href={featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'}>Open mix calculator</a>
{/if}
{#if canCreateMixSession}
<a href="/mix-calculator/new">Create mix session</a>
{/if}
{#if canOpenProducts}
<a href="/products">Review delivered pricing</a>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
{/if}
</div>
{/if}
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch}
<button
aria-expanded={quickMenuOpen}
aria-label="Open quick access menu"
@@ -385,6 +477,7 @@
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
<span>Quick Access</span>
</button>
{/if}
</div>
</div>
@@ -405,12 +498,11 @@
</nav>
{#if navOpen}
<section
<div
aria-label="Tablet navigation drawer"
class="bottom-drawer"
role="dialog"
aria-modal="true"
onclick={(event) => event.stopPropagation()}
>
<div class="drawer-handle"></div>
@@ -427,7 +519,7 @@
<WorkspaceSearchTrigger
className="drawer-search"
placeholder="Search the workspace..."
onClick={() => openPalette()}
onClick={() => canUseWorkspaceSearch && openPalette()}
/>
<div class="drawer-grid">
@@ -470,30 +562,40 @@
</nav>
<div class="drawer-section drawer-actions">
{#if canCreateMixWorksheet}
<a href="/mixes/new" onclick={() => (navOpen = false)}>
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
<span>Create mix worksheet</span>
</a>
{/if}
{#if canCreateMixSession}
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
<span>Create mix session</span>
</a>
{/if}
{#if canOpenSettings}
<button type="button" onclick={openSettings}>
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
<span>Change settings</span>
</button>
{/if}
{#if canOpenProducts}
<a href="/products" onclick={() => (navOpen = false)}>
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
<span>Review delivered pricing</span>
</a>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={() => openPalette('')}>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
<span>Search the workspace</span>
</button>
{/if}
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>
<button type="button" onclick={signOut}>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Sign out</span>
<span>Logout</span>
</button>
{/if}
</div>
@@ -507,7 +609,7 @@
</a>
{/each}
</div>
</section>
</div>
{/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;
+1 -1
View File
@@ -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 '';
}
@@ -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<MixCalculatorPreview | MixCalculatorSession | null>(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 @@
<p class="message error">{formError}</p>
{/if}
{#if !formError && formHint}
<p class="message hint">{formHint}</p>
{/if}
<div class="field-grid">
<label>
<span>Mix date</span>
<input bind:value={mixDate} disabled={!canEdit} type="date" />
</label>
<label>
<span>Prepared by</span>
<input bind:value={preparedByName} disabled={!canEdit} placeholder="Staff name" type="text" />
</label>
<label>
<span>Client</span>
<select bind:value={clientName} disabled={!canEdit}>
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching products.">
<option value="">Select a client</option>
{#each availableClients as client}
<option value={client}>{client}</option>
@@ -303,9 +328,13 @@
</select>
</label>
<label class="full-width">
<label>
<span>Product</span>
<select bind:value={productId} disabled={!canEdit || !filteredProducts.length}>
<select
bind:value={productId}
disabled={!canEdit || !clientName || !filteredProducts.length}
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No products are available for the selected client.' : 'Select a product.'}
>
<option value={0}>Select a product</option>
{#each filteredProducts as product}
<option value={product.product_id}>
@@ -317,12 +346,7 @@
<label>
<span>Batch size (kg)</span>
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="560" type="number" />
</label>
<label>
<span>Prepared by</span>
<input bind:value={preparedByName} disabled={!canEdit} placeholder="Staff name" type="text" />
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="Batch size" type="number" />
</label>
<label class="full-width">
@@ -554,6 +578,12 @@
color: #b2463f;
}
.message.hint {
background: var(--panel-soft);
color: var(--muted);
border: 1px solid var(--line);
}
.action-row {
margin-top: 1rem;
}
@@ -5,6 +5,7 @@
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation';
let {
brandHref,
currentPath,
primaryItems,
workingDocumentItems,
@@ -12,9 +13,11 @@
appVersion,
releaseStage,
currentYear,
canOpenSettings,
onOpenSettings,
onSignOut
}: {
brandHref: string;
currentPath: string;
primaryItems: NavItem[];
workingDocumentItems: NavItem[];
@@ -22,6 +25,7 @@
appVersion: string;
releaseStage: string;
currentYear: number;
canOpenSettings: boolean;
onOpenSettings: () => void;
onSignOut: () => void;
} = $props();
@@ -29,7 +33,7 @@
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">
<a class="brand" href={brandHref}>
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
</a>
</div>
@@ -75,18 +79,22 @@
<AppNavSection
ariaLabel="Account actions"
items={[
...(canOpenSettings
? [
{
label: 'Settings',
icon: Settings,
active: currentPath.startsWith('/settings'),
onSelect: onOpenSettings,
type: 'button'
},
type: 'button' as const
}
]
: []),
{
label: 'Sign out',
label: 'Logout',
icon: LogOut,
onSelect: onSignOut,
type: 'button'
type: 'button' as const
}
]}
/>
@@ -12,6 +12,8 @@
session,
userInitials,
userMenuOpen,
canUseWorkspaceSearch,
canOpenSettings,
onOpenPalette,
onToggleUserMenu,
onOpenSettings,
@@ -23,6 +25,8 @@
session: AppSession | null;
userInitials: string;
userMenuOpen: boolean;
canUseWorkspaceSearch: boolean;
canOpenSettings: boolean;
onOpenPalette: () => void;
onToggleUserMenu: () => void;
onOpenSettings: () => void;
@@ -47,9 +51,13 @@
</div>
</div>
{#if canUseWorkspaceSearch}
<div class="topbar-middle">
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
</div>
{:else}
<div class="topbar-middle"></div>
{/if}
<div class="topbar-actions">
<div class="menu-wrap user-menu-wrap">
@@ -86,10 +94,12 @@
</span>
</div>
</div>
{#if canOpenSettings}
<button type="button" class="menu-settings-btn" onclick={onOpenSettings}>
<Settings size={15} strokeWidth={1.75} />
Settings
</button>
{/if}
{#if session}
<button type="button" onclick={onSignOut}>Log out</button>
{:else if !sessionHydrated}
+5 -4
View File
@@ -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(() => {});
}
+2 -1
View File
@@ -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;
+38
View File
@@ -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);
});
});
+194
View File
@@ -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;
+244
View File
@@ -0,0 +1,244 @@
<script lang="ts">
let { error, status } = $props();
const title = $derived(status === 404 ? 'Page Not Found' : 'Something Went Wrong');
const detail = $derived(
status === 404
? 'That route does not exist in the Hunter Premium Produce workspace. Check the address or return to login.'
: error instanceof Error
? error.message
: 'The workspace hit an unexpected error while loading this page.'
);
</script>
<svelte:head>
<title>{title} | Hunter Premium Produce</title>
</svelte:head>
<section class="error-stage">
<div class="error-card">
<div class="error-backdrop" aria-hidden="true">
<span class="glow glow-one"></span>
<span class="glow glow-two"></span>
<span class="grid-band"></span>
</div>
<div class="error-header">
<div class="brand-lockup">
<img class="brand-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
<div>
<p class="eyebrow">Workspace Error</p>
<strong>Hunter Premium Produce</strong>
</div>
</div>
<span class="status-pill">{status}</span>
</div>
<div class="error-copy">
<p class="eyebrow">Route Response</p>
<h1>{title}</h1>
<p>{detail}</p>
</div>
<div class="error-actions">
<a class="primary-link" href="/">Return to Workspace</a>
<a class="secondary-link" href="/mix-calculator/new">Open Mix Calculator</a>
</div>
</div>
</section>
<style>
:global(body) {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(214, 234, 221, 0.86), transparent 36%),
linear-gradient(180deg, #f4f7f2 0%, #eef4ee 100%);
color: #1d3528;
font-family:
"Segoe UI",
system-ui,
sans-serif;
}
.error-stage {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.error-card {
position: relative;
overflow: hidden;
width: min(100%, 58rem);
padding: 2rem;
border: 1px solid rgba(32, 52, 41, 0.08);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 24px 60px rgba(39, 63, 52, 0.12);
}
.error-backdrop {
position: absolute;
inset: 0;
pointer-events: none;
}
.glow {
position: absolute;
border-radius: 999px;
filter: blur(16px);
opacity: 0.8;
}
.glow-one {
top: -3rem;
right: -2rem;
width: 15rem;
height: 15rem;
background: rgba(160, 199, 124, 0.25);
}
.glow-two {
bottom: -4rem;
left: -3rem;
width: 18rem;
height: 18rem;
background: rgba(214, 166, 90, 0.16);
}
.grid-band {
position: absolute;
inset: auto 0 0;
height: 8rem;
background:
linear-gradient(rgba(33, 54, 42, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(33, 54, 42, 0.07) 1px, transparent 1px);
background-size: 2rem 2rem;
mask-image: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.7));
}
.error-header,
.error-copy,
.error-actions {
position: relative;
z-index: 1;
}
.error-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.brand-lockup {
display: flex;
align-items: center;
gap: 0.9rem;
}
.brand-logo {
width: 3rem;
height: 3rem;
object-fit: contain;
}
.eyebrow {
margin: 0 0 0.22rem;
color: #5d7568;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status-pill {
padding: 0.45rem 0.72rem;
border-radius: 999px;
background: rgba(31, 53, 40, 0.08);
color: #244331;
font-size: 0.84rem;
font-weight: 700;
}
.error-copy {
margin-top: 2.2rem;
max-width: 36rem;
}
.error-copy h1 {
margin: 0;
font-size: clamp(2.4rem, 6vw, 4.8rem);
line-height: 0.95;
letter-spacing: -0.04em;
}
.error-copy p:last-child {
margin: 1rem 0 0;
color: #587063;
font-size: 1rem;
line-height: 1.6;
}
.error-actions {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin-top: 2rem;
}
.primary-link,
.secondary-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.8rem;
padding: 0.7rem 1.15rem;
border-radius: 0.9rem;
font-weight: 700;
text-decoration: none;
transition:
transform 140ms ease,
box-shadow 140ms ease,
background 140ms ease;
}
.primary-link {
background: #274934;
color: #fff;
box-shadow: 0 14px 28px rgba(39, 73, 52, 0.22);
}
.secondary-link {
border: 1px solid rgba(39, 73, 52, 0.14);
background: rgba(255, 255, 255, 0.72);
color: #244331;
}
.primary-link:hover,
.secondary-link:hover {
transform: translateY(-1px);
}
@media (max-width: 640px) {
.error-stage {
padding: 1rem;
}
.error-card {
padding: 1.35rem;
border-radius: 1.15rem;
}
.error-header {
align-items: flex-start;
flex-direction: column;
}
.error-actions {
flex-direction: column;
}
}
</style>
+60 -2
View File
@@ -1,10 +1,13 @@
<script lang="ts">
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { clientSession, sessionHydrated } from '$lib/session';
import Skeleton from '$lib/components/Skeleton.svelte';
import type { DashboardSummary } from '$lib/types';
import { getWorkspaceHomeHref } from '$lib/workspace-access';
import packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
import { tick } from 'svelte';
type Segment = {
label: string;
@@ -32,8 +35,11 @@
let email = $state('');
let password = $state('');
let isLoggingIn = $state(false);
let postLoginRedirecting = $state(false);
let loginError = $state('');
let passwordInput: HTMLInputElement | null = null;
let emailInput = $state<HTMLInputElement | null>(null);
let passwordInput = $state<HTMLInputElement | null>(null);
let loginFocusArmed = $state(true);
const currentYear = new Date().getFullYear();
const appVersion = `v${packageInfo.version}`;
const releaseStage = 'Beta';
@@ -50,11 +56,17 @@
// system. The response is shape-compatible with the legacy client
// session, so the rest of the app continues to work unchanged.
const session = await api.internalLogin(email, password);
const targetHref = getWorkspaceHomeHref(session);
postLoginRedirecting = targetHref !== '/';
clientSession.set(session);
if (targetHref !== '/') {
await goto(targetHref, { replaceState: true });
}
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
triggerPasswordShake();
} finally {
postLoginRedirecting = false;
isLoggingIn = false;
}
}
@@ -84,6 +96,18 @@
);
}
$effect(() => {
if ($sessionHydrated && !$clientSession) {
if (loginFocusArmed && emailInput) {
loginFocusArmed = false;
tick().then(() => emailInput?.focus());
}
return;
}
loginFocusArmed = true;
});
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
@@ -363,7 +387,13 @@
<form class="signin-form auth-form" onsubmit={handleLogin}>
<label class="field">
<span>Email</span>
<input bind:value={email} type="email" autocomplete="username" placeholder="Email" autofocus />
<input
bind:this={emailInput}
bind:value={email}
type="email"
autocomplete="username"
placeholder="Email"
/>
</label>
<label class="field field-password" class:is-invalid={Boolean(loginError)}>
@@ -401,6 +431,34 @@
</div>
</div>
</section>
{:else if postLoginRedirecting}
<section class="auth-stage auth-stage-loading">
<div class="auth-card auth-card-loading">
<div class="auth-header">
<div class="client-logo-block">
<img class="hero-login-logo" src="/logo-hsf.png" alt="Lean 101" />
<div class="client-logo-copy">
<p class="eyebrow">Opening Workspace</p>
<strong>Hunter Premium Produce</strong>
<span>Applying your role permissions now</span>
</div>
</div>
</div>
<div class="auth-copy">
<h2>Preparing your workspace.</h2>
<p>Routing you directly to the first area your role is allowed to open.</p>
</div>
<div class="auth-loading-panel">
<span class="loading-pulse" aria-hidden="true"></span>
<div>
<strong>Applying Access Rules</strong>
<p>Dashboard access is skipped for roles that do not have permission.</p>
</div>
</div>
</div>
</section>
{:else}
<section class="dashboard-intro">
<div class="greeting-row">
+4 -11
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenDashboard, getWorkspaceHomeHref } from '$lib/workspace-access';
import type { DashboardSummary } from '$lib/types';
const EMPTY_SUMMARY: DashboardSummary = {
@@ -18,18 +20,9 @@ export function load({ fetch }) {
return { summary: Promise.resolve(EMPTY_SUMMARY) };
}
// Skip data fetching for sessions that lack any dashboard-eligible module
// — the backend would just return nulls anyway.
const session = getStoredClientSession();
const permissions = session?.module_permissions ?? {};
const hasAnyDashboardData =
session?.role === 'admin' ||
permissions.dashboard ||
permissions.raw_materials ||
permissions.mix_master ||
permissions.products;
if (!hasAnyDashboardData) {
return { summary: Promise.resolve(EMPTY_SUMMARY) };
if (!canOpenDashboard(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
return {
+2 -2
View File
@@ -4,8 +4,8 @@
let { data } = $props();
let email = $state('admin@lean101.local');
let password = $state('lean101-admin');
let email = $state('');
let password = $state('');
let isLoggingIn = $state(false);
let loginError = $state('');
+10 -1
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { hasStoredAdminSession, hasStoredClientSession } from '$lib/session';
import { getStoredClientSession, hasStoredAdminSession, hasStoredClientSession } from '$lib/session';
import { canOpenClientAccess, getWorkspaceHomeHref } from '$lib/workspace-access';
function emptyPayload() {
return {
@@ -21,6 +23,13 @@ export async function load({ fetch }) {
return emptyPayload();
}
if (hasStoredClientSession()) {
const session = getStoredClientSession();
if (session && !canOpenClientAccess(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return { clients, exportPreview };
+5 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { featureFlags } from '$lib/features';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!featureFlags.mixCalculatorSessionHistory) {
@@ -15,10 +16,13 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenMixCalculator(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
sessions: hasModuleAccess(session, 'mix_calculator') ? await api.mixCalculatorSessions(fetch) : []
sessions: hasModuleAccess(session, 'mix_calculator') || session?.role === 'internal' ? await api.mixCalculatorSessions(fetch) : []
};
} catch {
return {
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canCreateMixSession, canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ params, fetch }) {
if (!hasStoredClientSession()) {
@@ -10,20 +12,17 @@ export async function load({ params, fetch }) {
}
const session = getStoredClientSession();
const canView = hasModuleAccess(session, 'mix_calculator');
const canEdit = hasModuleAccess(session, 'mix_calculator', 'edit');
const canView = canOpenMixCalculator(session);
const canEdit = canCreateMixSession(session);
if (!canView) {
return {
session: null,
options: { clients: [], products: [] }
};
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [savedSession, options] = await Promise.all([
api.mixCalculatorSession(Number(params.id), fetch),
canEdit ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
canEdit || session?.role === 'internal' ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
]);
return {
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ params, fetch }) {
if (!hasStoredClientSession()) {
@@ -10,10 +12,8 @@ export async function load({ params, fetch }) {
const session = getStoredClientSession();
if (!hasModuleAccess(session, 'mix_calculator')) {
return {
session: null
};
if (!canOpenMixCalculator(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canCreateMixSession, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -9,10 +11,13 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canCreateMixSession(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
options: hasModuleAccess(session, 'mix_calculator', 'edit')
options: hasModuleAccess(session, 'mix_calculator', 'edit') || session?.role === 'internal'
? await api.mixCalculatorOptions(fetch)
: { clients: [], products: [] }
};
+6 -1
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canOpenMixMaster, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -9,10 +11,13 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenMixMaster(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
mixes: hasModuleAccess(session, 'mix_master') ? await api.mixes(fetch) : []
mixes: hasModuleAccess(session, 'mix_master') || session?.role === 'internal' ? await api.mixes(fetch) : []
};
} catch {
return {
+5 -7
View File
@@ -1,6 +1,7 @@
import { error } from '@sveltejs/kit';
import { error, redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenMixMaster, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ params, fetch }) {
const mixId = Number(params.id);
@@ -17,17 +18,14 @@ export async function load({ params, fetch }) {
}
const session = getStoredClientSession();
if (!hasModuleAccess(session, 'mix_master')) {
return {
mix: null,
rawMaterials: []
};
if (!canOpenMixMaster(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [mix, rawMaterials] = await Promise.all([
api.mix(mixId, fetch),
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([])
hasModuleAccess(session, 'raw_materials') || session?.role === 'internal' ? api.rawMaterials(fetch) : Promise.resolve([])
]);
return {
+9 -1
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canCreateMixWorksheet, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -9,10 +11,16 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canCreateMixWorksheet(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
rawMaterials: hasModuleAccess(session, 'mix_master') && hasModuleAccess(session, 'raw_materials') ? await api.rawMaterials(fetch) : []
rawMaterials:
(hasModuleAccess(session, 'mix_master') && hasModuleAccess(session, 'raw_materials')) || session?.role === 'internal'
? await api.rawMaterials(fetch)
: []
};
} catch {
return {
+7 -2
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canOpenProducts, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -10,11 +12,14 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenProducts(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [products, productCosts] = await Promise.all([
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.productCosts(fetch) : Promise.resolve([])
]);
return {
products,
+9 -4
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canOpenRawMaterials, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -12,13 +14,16 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenRawMaterials(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [rawMaterials, mixes, products, productCosts] = await Promise.all([
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
hasModuleAccess(session, 'raw_materials') || session?.role === 'internal' ? api.rawMaterials(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') || session?.role === 'internal' ? api.mixes(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') || session?.role === 'internal' ? api.productCosts(fetch) : Promise.resolve([])
]);
return {
+16
View File
@@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenReporting, getWorkspaceHomeHref } from '$lib/workspace-access';
export function load() {
if (!hasStoredClientSession()) {
return {};
}
const session = getStoredClientSession();
if (session && !canOpenReporting(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
return {};
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
const apiMocks = vi.hoisted(() => ({
dashboardSummary: vi.fn()
}));
const sessionMocks = vi.hoisted(() => ({
getStoredClientSession: vi.fn(),
hasStoredClientSession: vi.fn(),
hasModuleAccess: vi.fn(),
hasPermission: vi.fn()
}));
vi.mock('$lib/api', () => ({
api: apiMocks
}));
vi.mock('$lib/session', () => sessionMocks);
import { load } from './+page';
describe('root route access', () => {
it('redirects operations users away from the dashboard route', () => {
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({
role: 'internal',
role_name: 'Operations',
permissions: ['view_mix_calculator']
});
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
sessionMocks.hasModuleAccess.mockReturnValue(false);
expect(() => load({ fetch: vi.fn() as typeof fetch })).toThrow(
expect.objectContaining({ status: 307, location: '/mix-calculator/new' })
);
});
it('loads the dashboard summary for users with dashboard access', () => {
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({ role: 'internal', permissions: ['view_dashboard'] });
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
apiMocks.dashboardSummary.mockResolvedValue({ ok: true });
const result = load({ fetch: vi.fn() as typeof fetch });
expect(apiMocks.dashboardSummary).toHaveBeenCalled();
expect(result).toHaveProperty('summary');
});
});
+5
View File
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canOpenScenarios, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
@@ -9,6 +11,9 @@ export async function load({ fetch }) {
}
const session = getStoredClientSession();
if (!canOpenScenarios(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
@@ -29,7 +29,6 @@
...$clientSession!,
name: updated.name,
email: updated.email,
token: updated.token ?? $clientSession!.token,
});
toast.dismiss(tid);
toast.success('Profile updated');
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenSettings, getWorkspaceHomeHref } from '$lib/workspace-access';
export function load() {
if (!hasStoredClientSession()) {
return {};
}
const session = getStoredClientSession();
if (!session) {
return {};
}
if (!canOpenSettings(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
return {};
}
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest';
const sessionMocks = vi.hoisted(() => ({
getStoredClientSession: vi.fn(),
hasStoredClientSession: vi.fn(),
hasModuleAccess: vi.fn(),
hasPermission: vi.fn()
}));
vi.mock('$lib/session', () => sessionMocks);
import { load } from './+page';
describe('settings route access', () => {
it('allows users with settings access', () => {
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({ role: 'internal', permissions: ['view_settings'] });
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
expect(load()).toEqual({});
});
it('redirects users without settings access to their allowed home route', () => {
sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({
role: 'internal',
role_name: 'Operations',
permissions: ['view_mix_calculator']
});
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
sessionMocks.hasModuleAccess.mockReturnValue(false);
expect(() => load()).toThrow(expect.objectContaining({ status: 307, location: '/mix-calculator/new' }));
});
});
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+27 -2
View File
@@ -1,13 +1,38 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
const backendTarget =
process.env.INTERNAL_API_BASE_URL?.trim() ||
process.env.PUBLIC_API_BASE_URL?.trim() ||
'http://127.0.0.1:8000';
export default defineConfig({
plugins: [sveltekit()],
server: {
host: '0.0.0.0'
host: '0.0.0.0',
proxy: {
'/api': {
target: backendTarget,
changeOrigin: true
},
'/health': {
target: backendTarget,
changeOrigin: true
}
}
},
preview: {
host: '0.0.0.0'
host: '0.0.0.0',
proxy: {
'/api': {
target: backendTarget,
changeOrigin: true
},
'/health': {
target: backendTarget,
changeOrigin: true
}
}
},
test: {
environment: 'node',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 693 KiB