Updates
This commit is contained in:
+18
-7
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Generated
+5
-5
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+52
-28
@@ -37,7 +37,6 @@ import type {
|
||||
} from '$lib/types';
|
||||
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
|
||||
|
||||
const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
|
||||
const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.';
|
||||
|
||||
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
|
||||
@@ -51,40 +50,62 @@ function getApiBaseUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
|
||||
if (configuredBaseUrl) {
|
||||
return configuredBaseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
|
||||
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
|
||||
if (configuredBaseUrl) {
|
||||
try {
|
||||
const configuredUrl = new URL(configuredBaseUrl, window.location.origin);
|
||||
// Keep browser API traffic same-origin by default. This avoids CORS,
|
||||
// CSP `connect-src`, and cookie policy failures when the backend is
|
||||
// reverse-proxied under `/api` on the same host.
|
||||
if (configuredUrl.origin === window.location.origin || configuredUrl.hostname === window.location.hostname) {
|
||||
return '';
|
||||
}
|
||||
return configuredUrl.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:${DEFAULT_API_PORT}`;
|
||||
const defaultApiPort = env.PUBLIC_API_PORT || '8000';
|
||||
return `http://127.0.0.1:${defaultApiPort}`;
|
||||
}
|
||||
|
||||
function buildApiUrl(path: string) {
|
||||
return `${getApiBaseUrl()}${path}`;
|
||||
}
|
||||
|
||||
function getToken(auth: AuthMode) {
|
||||
if (!browser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionFingerprint(auth: AuthMode) {
|
||||
if (auth === 'client') {
|
||||
return getStoredClientSession()?.token ?? null;
|
||||
const session = getStoredClientSession();
|
||||
return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : '';
|
||||
}
|
||||
|
||||
if (auth === 'admin') {
|
||||
return getStoredAdminSession()?.token ?? null;
|
||||
const session = getStoredAdminSession();
|
||||
return session ? `${session.role}:${session.email}` : '';
|
||||
}
|
||||
|
||||
if (auth === 'manager') {
|
||||
return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null;
|
||||
const admin = getStoredAdminSession();
|
||||
if (admin) {
|
||||
return `${admin.role}:${admin.email}`;
|
||||
}
|
||||
const client = getStoredClientSession();
|
||||
return client ? `${client.role}:${client.email}:${client.user_id ?? ''}` : '';
|
||||
}
|
||||
|
||||
return null;
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveRequestUrl(path: string, fetcher: ApiFetch) {
|
||||
if (fetcher !== fetch) {
|
||||
return path;
|
||||
}
|
||||
return buildApiUrl(path);
|
||||
}
|
||||
|
||||
function normalizeRequestError(error: unknown) {
|
||||
@@ -107,9 +128,8 @@ function normalizeRequestError(error: unknown) {
|
||||
|
||||
async function fetchJson<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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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,77 +386,98 @@
|
||||
|
||||
{#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">
|
||||
<a href="/mixes">Open mix costing</a>
|
||||
<a href="/mixes/new">Create mix worksheet</a>
|
||||
<a href={featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'}>Open mix calculator</a>
|
||||
<a href="/mix-calculator/new">Create mix session</a>
|
||||
<a href="/products">Review delivered pricing</a>
|
||||
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
||||
{#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}
|
||||
|
||||
<button
|
||||
aria-expanded={quickMenuOpen}
|
||||
aria-label="Open quick access menu"
|
||||
class="quick-fab"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
quickMenuOpen = !quickMenuOpen;
|
||||
userMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
|
||||
<span>Quick Access</span>
|
||||
</button>
|
||||
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch}
|
||||
<button
|
||||
aria-expanded={quickMenuOpen}
|
||||
aria-label="Open quick access menu"
|
||||
class="quick-fab"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
quickMenuOpen = !quickMenuOpen;
|
||||
userMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<a href="/mixes/new" onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
|
||||
<span>Create mix worksheet</span>
|
||||
</a>
|
||||
<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>
|
||||
<button type="button" onclick={openSettings}>
|
||||
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
|
||||
<span>Change settings</span>
|
||||
</button>
|
||||
<a href="/products" onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
|
||||
<span>Review delivered pricing</span>
|
||||
</a>
|
||||
<button type="button" onclick={() => openPalette('')}>
|
||||
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
|
||||
<span>Search the workspace</span>
|
||||
</button>
|
||||
{#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;
|
||||
|
||||
@@ -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' as const
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
active: currentPath.startsWith('/settings'),
|
||||
onSelect: onOpenSettings,
|
||||
type: 'button'
|
||||
},
|
||||
{
|
||||
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>
|
||||
|
||||
<div class="topbar-middle">
|
||||
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
|
||||
</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>
|
||||
<button type="button" class="menu-settings-btn" onclick={onOpenSettings}>
|
||||
<Settings size={15} strokeWidth={1.75} />
|
||||
Settings
|
||||
</button>
|
||||
{#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,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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: [] }
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
+27
-2
@@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 693 KiB |
Reference in New Issue
Block a user