From 1533b5aa9be3073dc8b3c006753b266d234764cb Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Fri, 8 May 2026 00:00:56 +1200 Subject: [PATCH] Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements --- backend/app/api/access.py | 177 ++++++ backend/app/api/dashboard.py | 150 +++++ backend/app/api/deps.py | 69 ++- backend/app/core/access.py | 193 +++++++ backend/app/core/security.py | 38 ++ backend/app/main.py | 4 + backend/app/models/__init__.py | 5 + backend/app/models/access.py | 66 +++ backend/app/seed.py | 2 + backend/app/seed_access.py | 164 ++++++ .../app/services/mix_calculator_service.py | 46 +- backend/tests/test_access.py | 236 ++++++++ frontend/package-lock.json | 32 +- frontend/package.json | 3 + frontend/src/lib/api.ts | 105 +++- .../src/lib/components/ClientShell.svelte | 532 ++++++++++-------- .../components/MixCalculatorWorkspace.svelte | 25 +- frontend/src/lib/components/Skeleton.svelte | 61 ++ frontend/src/lib/session.ts | 24 + frontend/src/lib/types.ts | 37 ++ frontend/src/routes/+page.svelte | 243 ++++---- frontend/src/routes/+page.ts | 64 +-- .../src/routes/client-access/+page.svelte | 8 - .../src/routes/mix-calculator/+page.svelte | 30 +- frontend/src/routes/mixes/+page.svelte | 20 +- frontend/src/routes/products/+page.svelte | 8 - .../src/routes/raw-materials/+page.svelte | 13 - frontend/src/routes/scenarios/+page.svelte | 8 - frontend/src/routes/settings/+page.svelte | 8 - 29 files changed, 1851 insertions(+), 520 deletions(-) create mode 100644 backend/app/api/access.py create mode 100644 backend/app/api/dashboard.py create mode 100644 backend/app/core/access.py create mode 100644 backend/app/models/access.py create mode 100644 backend/app/seed_access.py create mode 100644 backend/tests/test_access.py create mode 100644 frontend/src/lib/components/Skeleton.svelte diff --git a/backend/app/api/access.py b/backend/app/api/access.py new file mode 100644 index 0000000..0b50aff --- /dev/null +++ b/backend/app/api/access.py @@ -0,0 +1,177 @@ +"""Internal-user authentication and permission introspection routes. + +Frontends should call ``GET /api/access/me`` to discover which permission keys +the current user has, then use those keys to hide/show navigation items. +**Visibility is not security** — every privileged backend route must depend on +``require_permission`` (or one of its siblings) directly. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.core.access import ( + INTERNAL_USER_SUBJECT, + INTERNAL_USER_TENANT_ID, + get_current_user, + get_user_permissions, + permissions_to_module_map, + require_permission, +) +from app.core.config import settings +from app.core.security import issue_token +from app.db.session import get_db +from app.models.access import Permission, Role, User + + +router = APIRouter(prefix="/api/access", tags=["access"]) + + +class LoginRequest(BaseModel): + email: str + password: str + + +class UserSession(BaseModel): + # Mirrors the existing `LoginResponse` shape so the frontend's `AppSession` + # store can consume this response without a separate type. `permissions` + # is the new permission-key array; `module_permissions` is the legacy + # module→access-level map for nav gating. + user_id: int + email: str + name: str + role: str + role_name: str | None = None + is_active: bool + tenant_id: str | None = None + client_role: str | None = None + client_account_id: int | None = None + module_permissions: dict[str, str] = {} + permissions: list[str] + token: str | None = None + + +class RoleRead(BaseModel): + id: int + name: str + description: str | None + permissions: list[str] + + +class UserRead(BaseModel): + id: int + email: str + name: str + is_active: bool + role: str | None + + +def _serialize_session(user: User, *, include_token: bool = False) -> UserSession: + permission_set = get_user_permissions(user) + permissions = sorted(permission_set) + module_permissions = permissions_to_module_map(permission_set) + 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}) + # 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. + return UserSession( + user_id=user.id, + email=user.email, + name=user.name, + role="internal", + role_name=role_name, + is_active=user.is_active, + tenant_id=INTERNAL_USER_TENANT_ID, + client_role=role_name, + client_account_id=None, + module_permissions=module_permissions, + permissions=permissions, + token=token, + ) + + +@router.post("/login", response_model=UserSession) +def login(payload: LoginRequest, 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. + """ + if payload.password != settings.admin_password: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + + email = payload.email.strip().lower() + user = db.scalar( + select(User) + .where(User.email == email) + .options(selectinload(User.role).selectinload(Role.permissions)) + ) + if user is None or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + + return _serialize_session(user, include_token=True) + + +@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) + + +@router.get("/me/permissions", response_model=list[str]) +def read_my_permissions(user: User = Depends(get_current_user)): + return sorted(get_user_permissions(user)) + + +# Permission-enforced administrative endpoints. Route bodies should not check +# role names — every gate is the require_permission(...) dependency. + +@router.get("/users", response_model=list[UserRead]) +def list_users( + db: Session = Depends(get_db), + _: User = Depends(require_permission("view_users")), # gated by permission key +): + users = db.scalars(select(User).options(selectinload(User.role))).all() + return [ + UserRead( + id=user.id, + email=user.email, + name=user.name, + is_active=user.is_active, + role=user.role.name if user.role else None, + ) + for user in users + ] + + +@router.get("/roles", response_model=list[RoleRead]) +def list_roles( + db: Session = Depends(get_db), + _: User = Depends(require_permission("manage_permissions")), # gated by permission key +): + roles = db.scalars( + select(Role).options(selectinload(Role.permissions)).order_by(Role.name) + ).all() + return [ + RoleRead( + id=role.id, + name=role.name, + description=role.description, + permissions=sorted(p.key for p in role.permissions), + ) + for role in roles + ] + + +@router.get("/permissions", response_model=list[str]) +def list_permissions( + db: Session = Depends(get_db), + _: User = Depends(require_permission("manage_permissions")), # gated by permission key +): + return sorted(p.key for p in db.scalars(select(Permission)).all()) diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..0988b15 --- /dev/null +++ b/backend/app/api/dashboard.py @@ -0,0 +1,150 @@ +"""Dashboard summary endpoint. + +Returns only the aggregates the homepage actually renders — counts, top items, +totals, and a trend-chart series. Replaces a Dashboard load that previously +fetched five full collections (raw materials, mixes, all product cost +breakdowns, scenarios, data-quality) and only used summaries from each. +""" +from __future__ import annotations + +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.db.session import get_db +from app.models.mix import Mix +from app.models.product import Product +from app.models.raw_material import RawMaterial +from app.services.client_access_service import has_access_level +from app.services.costing_engine import ( + calculate_mix_cost, + calculate_product_cost, + get_active_price, + calculate_raw_material_cost, +) + + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + + +def _can(session: AuthSession, module_key: str) -> bool: + permissions = session.module_permissions or {} + return has_access_level(permissions.get(module_key), "view") + + +@router.get("/summary") +def dashboard_summary( + session: AuthSession = Depends(require_client_session), + db: Session = Depends(get_db), +): + raw_materials_summary: dict | None = None + mixes_summary: dict | None = None + products_summary: dict | None = None + raw_series: list[float] = [] + mix_series: list[float] = [] + product_series: list[float] = [] + + if _can(session, "raw_materials") or _can(session, "dashboard"): + materials = db.scalars( + select(RawMaterial) + .where(RawMaterial.tenant_id == session.tenant_id) + .options(selectinload(RawMaterial.price_versions)) + ).all() + + total_market_value = 0.0 + latest = None + latest_date = None + for material in materials: + active = get_active_price(material) + if active is None: + continue + comp = calculate_raw_material_cost(material, active) + raw_series.append(comp.cost_per_kg) + total_market_value += active.market_value + if latest_date is None or active.effective_date > latest_date: + latest_date = active.effective_date + latest = { + "id": material.id, + "name": material.name, + "market_value": active.market_value, + "cost_per_kg": comp.cost_per_kg, + "effective_date": active.effective_date.isoformat() if active.effective_date else None, + } + + raw_materials_summary = { + "count": len(materials), + "total_market_value": round(total_market_value, 4), + "latest": latest, + } + + if _can(session, "mix_master") or _can(session, "dashboard"): + mix_rows = db.scalars( + select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name) + ).all() + cost_sum = 0.0 + cost_count = 0 + top_mix: dict | None = None + for mix in mix_rows: + result = calculate_mix_cost(db, mix.id) + cost_per_kg = result.get("mix_cost_per_kg") + if cost_per_kg is not None: + mix_series.append(cost_per_kg) + cost_sum += cost_per_kg + cost_count += 1 + if top_mix is None or (cost_per_kg or 0) > (top_mix.get("mix_cost_per_kg") or 0): + top_mix = { + "id": result["id"], + "name": result["name"], + "client_name": result["client_name"], + "ingredients_count": len(result["ingredients"]), + "total_mix_kg": result["total_mix_kg"], + "total_mix_cost": result["total_mix_cost"], + "mix_cost_per_kg": cost_per_kg, + "warnings": result["warnings"], + } + + mixes_summary = { + "count": len(mix_rows), + "average_cost_per_kg": round(cost_sum / cost_count, 4) if cost_count else 0.0, + "top": top_mix, + } + + if _can(session, "products") or _can(session, "dashboard"): + products = db.scalars( + select(Product).where(Product.tenant_id == session.tenant_id) + ).all() + rows: list[dict] = [] + for product in products: + result = calculate_product_cost(db, product.id) + finished = result.get("finished_product_delivered") or 0.0 + product_series.append(finished) + rows.append( + { + "id": product.id, + "product_name": result["product_name"], + "client_name": result["client_name"], + "finished_product_delivered": finished, + "warnings": result["warnings"], + } + ) + + rows.sort(key=lambda row: row["finished_product_delivered"], reverse=True) + products_summary = { + "count": len(products), + "top": rows[0] if rows else None, + "top_products": rows[:4], + } + + return { + "raw_materials": raw_materials_summary, + "mixes": mixes_summary, + "products": products_summary, + # Pre-computed numeric series for the homepage trend chart so the + # client doesn't need full collections to draw it. + "trend_seeds": { + "raw_material_cost_per_kg": raw_series, + "mix_cost_per_kg": mix_series, + "product_finished_delivered": product_series, + }, + } diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index d1a8c11..de153d7 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -7,8 +7,15 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.orm import Session, selectinload +from app.core.access import ( + INTERNAL_USER_SUBJECT, + INTERNAL_USER_TENANT_ID, + get_user_permissions, + permissions_to_module_map, +) 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 @@ -27,11 +34,52 @@ class AuthSession: module_permissions: dict[str, str] | None = None -def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession: +def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession: + """Translate an internal-user token into an AuthSession the shared route + dependencies can consume. Internal users present `role="internal"` so + `require_client_module_access` can spot them and skip the ClientUser DB + lookup, deriving their module_permissions from the role-permission table. + """ + user_id = payload.get("user_id") + if not isinstance(user_id, int): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token") + + user = db.scalar( + select(User) + .where(User.id == user_id) + .options(selectinload(User.role).selectinload(Role.permissions)) + ) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive") + + return AuthSession( + role="internal", + email=user.email, + name=user.name, + tenant_id=INTERNAL_USER_TENANT_ID, + client_role=user.role.name if user.role else None, + user_id=user.id, + client_account_id=None, + module_permissions=permissions_to_module_map(get_user_permissions(user)), + ) + + +def get_auth_session( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> AuthSession: if credentials is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") payload = verify_token(credentials.credentials) + + # Internal Hunter Stock Feeds users get an auth session derived from the + # role/permission tables rather than the client-portal ClientUser tables. + if payload.get("sub") == INTERNAL_USER_SUBJECT: + return _build_internal_auth_session(db, payload) + return AuthSession( role=str(payload.get("role", "")), email=str(payload.get("email", "")), @@ -45,6 +93,13 @@ def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends( def require_client_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession: + # Internal Hunter Stock Feeds users share the workspace with client users + # but don't have a ClientUser row, so we accept them here and let + # `require_client_module_access` enforce the per-module checks. + if session.role == "internal": + if not session.tenant_id or not session.user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Internal user context is missing") + return session if session.role != "client": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required") if not session.tenant_id: @@ -82,6 +137,18 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"): session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db), ) -> AuthSession: + # Internal users have their permissions baked into the AuthSession at + # token-resolve time; skip the ClientUser/feature DB lookups and check + # the in-memory module_permissions map directly. + if session.role == "internal": + permissions = session.module_permissions or {} + if not has_access_level(permissions.get(module_key), minimum_level): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"{module_key} access is not permitted", + ) + return session + user = load_current_client_user(db, session) feature = db.scalar( select(ClientFeatureAccess).where( diff --git a/backend/app/core/access.py b/backend/app/core/access.py new file mode 100644 index 0000000..4ba32b1 --- /dev/null +++ b/backend/app/core/access.py @@ -0,0 +1,193 @@ +"""Database-driven role/permission access control. + +This module is the single source of truth for permission enforcement on +internal Hunter Stock Feeds users. Route handlers should depend on +``require_permission`` / ``require_any_permission`` / ``require_all_permissions`` +rather than checking role names. To change access later, update the database +seed (``app.seed_access``) — no route code should need to change. + +Fail-closed rules enforced here: + * Missing or invalid token -> 401 + * Unknown user_id -> 401 + * Inactive user -> 403 + * No role / missing perm -> 403 +""" +from __future__ import annotations + +from typing import Iterable + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.core.security import verify_token +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. +INTERNAL_USER_SUBJECT = "internal_user" + +# Default tenant internal Hunter Stock Feeds users operate against. Internal +# users see the production workspace data, so they read/write the same tenant +# the costing seed populates. +INTERNAL_USER_TENANT_ID = "hunter-premium-produce" + +# Maps the internal permission keys onto the legacy module/access-level shape +# the existing client-portal routes already understand. This lets the shared +# routes (raw materials, mixes, products, etc.) accept either token type +# without having to be rewritten. +_PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = { + "view_dashboard": ("dashboard", "view"), + "view_mix_calculator": ("mix_calculator", "view"), + "use_mix_calculator": ("mix_calculator", "edit"), + "save_mix_calculator_session": ("mix_calculator", "edit"), + "view_raw_materials": ("raw_materials", "view"), + "edit_raw_materials": ("raw_materials", "edit"), + "view_products": ("products", "view"), + "edit_products": ("products", "edit"), + "view_mixes": ("mix_master", "view"), + "edit_mixes": ("mix_master", "edit"), + # Admin-only permissions (view_users, manage_users, manage_permissions, + # view_settings, edit_settings) are intentionally excluded — they don't + # correspond to any of the legacy module keys and remain accessible only + # via the explicit `require_permission(...)` dependency. +} + +_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3} + + +def permissions_to_module_map(permission_keys: set[str]) -> dict[str, str]: + """Translate internal permission keys into the legacy module→level map. + + Higher levels (edit > view) win when multiple permissions map to the same + module key (e.g. ``edit_raw_materials`` overrides ``view_raw_materials``). + """ + result: dict[str, str] = {} + for key in permission_keys: + mapping = _PERMISSION_TO_MODULE_LEVEL.get(key) + if not mapping: + continue + module_key, level = mapping + if _ACCESS_LEVEL_RANK[level] > _ACCESS_LEVEL_RANK.get(result.get(module_key, "none"), 0): + result[module_key] = level + return result + + +def get_user_permissions(user: User | None) -> set[str]: + """Return the set of permission keys granted to ``user``. + + Returns an empty set for unknown users, inactive users, or users without + a role. This means downstream checks fail closed by default. + """ + if user is None or not user.is_active or user.role is None: + return set() + return {permission.key for permission in user.role.permissions} + + +def user_has_permission(user: User | None, permission_key: str) -> bool: + return permission_key in get_user_permissions(user) + + +def _load_user(db: Session, user_id: int) -> User | None: + return db.scalar( + select(User) + .where(User.id == user_id) + .options(selectinload(User.role).selectinload(Role.permissions)) + ) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> User: + """Resolve the current internal user from the bearer token. + + Raises 401 for missing/invalid tokens or unknown users, 403 for inactive + users. + """ + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") + + payload = verify_token(credentials.credentials) + if payload.get("sub") != INTERNAL_USER_SUBJECT: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token") + + user_id = payload.get("user_id") + if not isinstance(user_id, int): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token") + + user = _load_user(db, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive") + + return user + + +def require_permission(permission_key: str): + """FastAPI dependency factory enforcing a single permission key.""" + + def dependency(user: User = Depends(get_current_user)) -> User: + if not user_has_permission(user, permission_key): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permission: {permission_key}", + ) + return user + + return dependency + + +def require_any_permission(permission_keys: Iterable[str]): + """FastAPI dependency factory: pass if the user has any of the keys.""" + keys = tuple(permission_keys) + + def dependency(user: User = Depends(get_current_user)) -> User: + granted = get_user_permissions(user) + if not any(key in granted for key in keys): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires any of: {list(keys)}", + ) + return user + + return dependency + + +def require_all_permissions(permission_keys: Iterable[str]): + """FastAPI dependency factory: pass only if the user has every key.""" + keys = tuple(permission_keys) + + def dependency(user: User = Depends(get_current_user)) -> User: + granted = get_user_permissions(user) + missing = [key for key in keys if key not in granted] + if missing: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permissions: {missing}", + ) + return user + + return dependency + + +__all__ = [ + "INTERNAL_USER_SUBJECT", + "Permission", + "Role", + "User", + "bearer_scheme", + "get_current_user", + "get_user_permissions", + "require_all_permissions", + "require_any_permission", + "require_permission", + "user_has_permission", +] diff --git a/backend/app/core/security.py b/backend/app/core/security.py index a176075..dfe309b 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -48,3 +48,41 @@ def verify_token(token: str) -> dict[str, Any]: if int(payload.get("exp", 0)) < int(time.time()): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication token has expired") return payload + + +# --- Password hashing ------------------------------------------------------ +# PBKDF2-SHA256 with a per-password 16-byte salt and 200k iterations. Stored +# as `pbkdf2_sha256$iterations$salt_hex$hash_hex`. No external dep needed. + +import os +import secrets + +_PBKDF2_ITERATIONS = 200_000 +_PBKDF2_ALGO = "pbkdf2_sha256" + + +def hash_password(password: str) -> str: + if not password: + raise ValueError("Password cannot be empty") + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS) + return f"{_PBKDF2_ALGO}${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, encoded: str | None) -> bool: + if not encoded or not password: + return False + try: + algo, iterations_str, salt_hex, digest_hex = encoded.split("$", 3) + except ValueError: + return False + if algo != _PBKDF2_ALGO: + return False + try: + iterations = int(iterations_str) + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(digest_hex) + except ValueError: + return False + candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return hmac.compare_digest(candidate, expected) diff --git a/backend/app/main.py b/backend/app/main.py index 0c75f1f..a7a43d8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,8 +12,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware import uvicorn +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 +from app.api.dashboard import router as dashboard_router from app.api.mix_calculator import router as mix_calculator_router from app.api.mixes import router as mixes_router from app.api.powerbi import router as powerbi_router @@ -72,7 +74,9 @@ app.add_middleware( ) app.include_router(auth_router) +app.include_router(access_router) app.include_router(client_access_router) +app.include_router(dashboard_router) app.include_router(raw_materials_router) app.include_router(mixes_router) app.include_router(mix_calculator_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2a4fc7d..178c064 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,4 @@ +from app.models.access import Permission, Role, User, role_permissions from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine @@ -19,9 +20,13 @@ __all__ = [ "MixCalculatorSessionLine", "MixIngredient", "PackagingCostRule", + "Permission", "ProcessCostRule", "Product", "RawMaterial", "RawMaterialPriceVersion", + "Role", "Scenario", + "User", + "role_permissions", ] diff --git a/backend/app/models/access.py b/backend/app/models/access.py new file mode 100644 index 0000000..3816ff1 --- /dev/null +++ b/backend/app/models/access.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base + + +role_permissions = Table( + "role_permissions", + Base.metadata, + Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True), + Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True), +) + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + permissions: Mapped[list["Permission"]] = relationship( + secondary=role_permissions, + back_populates="roles", + lazy="selectin", + ) + users: Mapped[list["User"]] = relationship(back_populates="role") + + +class Permission(Base): + __tablename__ = "permissions" + + id: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] = mapped_column(String(128), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + roles: Mapped[list["Role"]] = relationship( + secondary=role_permissions, + back_populates="permissions", + ) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255)) + role_id: Mapped[int | None] = mapped_column(ForeignKey("roles.id"), nullable=True, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + # Per-user password hash (PBKDF2-SHA256). Null while a user has never set + # a personal password — they can still sign in with the shared internal + # password until they choose one in settings. + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + role: Mapped["Role | None"] = relationship(back_populates="users", lazy="selectin") diff --git a/backend/app/seed.py b/backend/app/seed.py index 54fb8f4..27da275 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -15,6 +15,7 @@ from app.models.client_access import ClientAccessAuditEvent, ClientAccount, Clie from app.models.mix import Mix, MixIngredient from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion +from app.seed_access import seed_access from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role @@ -688,6 +689,7 @@ def seed_if_empty(): else: logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH) seed_client_access(db) + seed_access(db) db.commit() diff --git a/backend/app/seed_access.py b/backend/app/seed_access.py new file mode 100644 index 0000000..dd62fb0 --- /dev/null +++ b/backend/app/seed_access.py @@ -0,0 +1,164 @@ +"""Idempotent seed for roles, permissions, and the Hunter Stock Feeds users. + +Re-running this is safe: it upserts permissions, syncs each role's permission +set to the declared list, and creates or updates the seed users without +duplicating rows. Permission grants are the source of truth — change them +here (or in the DB) rather than in route code. +""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.models.access import Permission, Role, User + + +PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = ( + ("view_dashboard", "View the operational dashboard"), + ("view_mix_calculator", "View the mix calculator area"), + ("use_mix_calculator", "Run calculations in the mix calculator"), + ("save_mix_calculator_session", "Save mix calculator sessions"), + ("view_raw_materials", "View raw materials"), + ("edit_raw_materials", "Create and edit raw materials"), + ("view_products", "View finished products"), + ("edit_products", "Create and edit finished products"), + ("view_mixes", "View mix master recipes"), + ("edit_mixes", "Create and edit mix master recipes"), + ("view_users", "View internal users and roles"), + ("manage_users", "Create, deactivate, and assign user roles"), + ("manage_permissions", "Modify roles and role-permission assignments"), + ("view_settings", "View system settings"), + ("edit_settings", "Edit system settings"), +) + + +ROLE_DEFINITIONS: dict[str, dict] = { + "Admin": { + "description": "Full administrative access including user and permission management.", + "permissions": [ + "view_dashboard", + "view_mix_calculator", + "use_mix_calculator", + "save_mix_calculator_session", + "view_raw_materials", + "edit_raw_materials", + "view_products", + "view_mixes", + "view_users", + "manage_users", + "manage_permissions", + "view_settings", + "edit_settings", + ], + }, + "Operations": { + "description": "Mix calculator only — cannot edit raw materials, products, mixes, users, or settings.", + "permissions": [ + "view_mix_calculator", + "use_mix_calculator", + "save_mix_calculator_session", + ], + }, + "Full Access": { + "description": "Operational data editor — cannot manage users or permissions unless explicitly granted.", + "permissions": [ + "view_dashboard", + "view_mix_calculator", + "use_mix_calculator", + "save_mix_calculator_session", + "view_raw_materials", + "edit_raw_materials", + "view_products", + "edit_products", + "view_mixes", + "edit_mixes", + ], + }, +} + + +SEED_USERS: tuple[dict, ...] = ( + { + "email": "admin@hunterstockfeeds.com", + "name": "Hunter Stock Feeds Admin", + "role": "Admin", + }, + { + "email": "ops@hunterstockfeeds.com", + "name": "Hunter Stock Feeds Operations", + "role": "Operations", + }, + { + "email": "craig@hunterstockfeeds.com", + "name": "Craig", + "role": "Full Access", + }, +) + + +def _upsert_permissions(db: Session) -> dict[str, Permission]: + existing = {permission.key: permission for permission in db.scalars(select(Permission)).all()} + for key, description in PERMISSION_DEFINITIONS: + permission = existing.get(key) + if permission is None: + permission = Permission(key=key, description=description) + db.add(permission) + existing[key] = permission + elif permission.description != description: + permission.description = description + db.flush() + return existing + + +def _upsert_roles(db: Session, permissions_by_key: dict[str, Permission]) -> dict[str, Role]: + existing = { + role.name: role + for role in db.scalars( + select(Role).options(selectinload(Role.permissions)) + ).all() + } + + for role_name, definition in ROLE_DEFINITIONS.items(): + role = existing.get(role_name) + if role is None: + role = Role(name=role_name, description=definition["description"]) + db.add(role) + existing[role_name] = role + elif role.description != definition["description"]: + role.description = definition["description"] + db.flush() + + desired = {permissions_by_key[key] for key in definition["permissions"]} + current = set(role.permissions) + for permission in desired - current: + role.permissions.append(permission) + for permission in current - desired: + role.permissions.remove(permission) + + db.flush() + return existing + + +def _upsert_users(db: Session, roles_by_name: dict[str, Role]) -> None: + existing = {user.email: user for user in db.scalars(select(User)).all()} + for entry in SEED_USERS: + email = entry["email"].lower() + role = roles_by_name[entry["role"]] + user = existing.get(email) + if user is None: + user = User(email=email, name=entry["name"], role_id=role.id, is_active=True) + db.add(user) + existing[email] = user + else: + user.name = entry["name"] + user.role_id = role.id + if not user.is_active: + user.is_active = True + db.flush() + + +def seed_access(db: Session) -> None: + """Idempotent: roles, permissions, role-permission links, seed users.""" + permissions_by_key = _upsert_permissions(db) + roles_by_name = _upsert_roles(db, permissions_by_key) + _upsert_users(db, roles_by_name) diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py index a083ad6..05c9492 100644 --- a/backend/app/services/mix_calculator_service.py +++ b/backend/app/services/mix_calculator_service.py @@ -2,8 +2,8 @@ from __future__ import annotations from datetime import date -from sqlalchemy import select -from sqlalchemy.orm import Session, selectinload +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload, selectinload from app.api.deps import AuthSession from app.models.mix import Mix, MixIngredient @@ -108,29 +108,39 @@ def calculate_mix_calculator_preview( def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: + # Aggregate mix totals in a single query instead of loading every + # ingredient row for every product. The previous implementation was the + # main slow path on first Mix Calculator open — it streamed the entire + # tenant's recipe table just to compute one sum per product. + mix_totals_rows = db.execute( + select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0)) + .join(Mix, Mix.id == MixIngredient.mix_id) + .where(Mix.tenant_id == tenant_id) + .group_by(MixIngredient.mix_id) + ).all() + mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows} + products = db.scalars( select(Product) .where(Product.tenant_id == tenant_id) - .options(selectinload(Product.mix).selectinload(Mix.ingredients)) + .options(joinedload(Product.mix)) .order_by(Product.client_name, Product.name) ).all() - product_rows = [] clients = sorted({product.client_name for product in products}) - for product in products: - mix_total_kg = round(sum(ingredient.quantity_kg for ingredient in (product.mix.ingredients if product.mix else [])), 4) - product_rows.append( - { - "product_id": product.id, - "client_name": product.client_name, - "product_name": product.name, - "mix_id": product.mix_id, - "mix_name": product.mix.name if product.mix else "", - "unit_of_measure": product.unit_of_measure, - "unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4), - "mix_total_kg": mix_total_kg, - } - ) + product_rows = [ + { + "product_id": product.id, + "client_name": product.client_name, + "product_name": product.name, + "mix_id": product.mix_id, + "mix_name": product.mix.name if product.mix else "", + "unit_of_measure": product.unit_of_measure, + "unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4), + "mix_total_kg": mix_totals.get(product.mix_id, 0.0), + } + for product in products + ] return {"clients": clients, "products": product_rows} diff --git a/backend/tests/test_access.py b/backend/tests/test_access.py new file mode 100644 index 0000000..15d7512 --- /dev/null +++ b/backend/tests/test_access.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.access import ( + INTERNAL_USER_SUBJECT, + get_user_permissions, + require_all_permissions, + require_any_permission, + require_permission, + user_has_permission, +) +from app.core.security import issue_token +from app.db.session import Base, get_db +from app.models.access import Permission, Role, User +from app.seed_access import PERMISSION_DEFINITIONS, ROLE_DEFINITIONS, SEED_USERS, seed_access + + +def _build_session() -> Session: + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, expire_on_commit=False)() + + +# --- Seed + permission lookup ---------------------------------------------- + + +def test_seed_creates_roles_permissions_and_users(): + db = _build_session() + seed_access(db) + db.commit() + + assert {role.name for role in db.query(Role).all()} == set(ROLE_DEFINITIONS.keys()) + assert {p.key for p in db.query(Permission).all()} == {key for key, _ in PERMISSION_DEFINITIONS} + assert {user.email for user in db.query(User).all()} == {entry["email"] for entry in SEED_USERS} + + +def test_seed_is_idempotent(): + db = _build_session() + seed_access(db) + db.commit() + seed_access(db) + db.commit() + + assert db.query(User).count() == len(SEED_USERS) + assert db.query(Role).count() == len(ROLE_DEFINITIONS) + assert db.query(Permission).count() == len(PERMISSION_DEFINITIONS) + + +def test_admin_role_permissions_match_spec(): + db = _build_session() + seed_access(db) + + admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one() + granted = get_user_permissions(admin) + + assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"]) + assert "manage_users" in granted + assert "manage_permissions" in granted + # Admin spec deliberately excludes edit_products / edit_mixes. + assert "edit_products" not in granted + assert "edit_mixes" not in granted + + +def test_operations_role_is_mix_calculator_only(): + db = _build_session() + seed_access(db) + + ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one() + granted = get_user_permissions(ops) + + assert granted == {"view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"} + assert not user_has_permission(ops, "edit_raw_materials") + assert not user_has_permission(ops, "view_dashboard") + assert not user_has_permission(ops, "manage_users") + + +def test_full_access_role_can_edit_operational_data_but_not_users(): + db = _build_session() + seed_access(db) + + craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one() + granted = get_user_permissions(craig) + + assert {"edit_raw_materials", "edit_products", "edit_mixes", "save_mix_calculator_session"} <= granted + assert "manage_users" not in granted + assert "manage_permissions" not in granted + + +def test_inactive_user_has_no_permissions(): + db = _build_session() + seed_access(db) + + admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one() + admin.is_active = False + + assert get_user_permissions(admin) == set() + assert not user_has_permission(admin, "view_dashboard") + + +def test_unknown_user_has_no_permissions(): + assert get_user_permissions(None) == set() + assert not user_has_permission(None, "view_dashboard") + + +def test_user_without_role_has_no_permissions(): + db = _build_session() + seed_access(db) + + orphan = User(email="nobody@hunterstockfeeds.com", name="Nobody", role_id=None, is_active=True) + db.add(orphan) + db.flush() + + assert get_user_permissions(orphan) == set() + + +# --- Route-level enforcement ----------------------------------------------- + + +@pytest.fixture() +def app_and_db(): + db = _build_session() + seed_access(db) + db.commit() + + app = FastAPI() + + def override_get_db(): + yield db + + app.dependency_overrides[get_db] = override_get_db + + @app.get("/needs-edit-raw") + def needs_edit_raw(_: User = Depends(require_permission("edit_raw_materials"))): + return {"ok": True} + + @app.get("/needs-any") + def needs_any(_: User = Depends(require_any_permission(["edit_raw_materials", "manage_users"]))): + return {"ok": True} + + @app.get("/needs-all") + def needs_all(_: User = Depends(require_all_permissions(["view_raw_materials", "edit_raw_materials"]))): + return {"ok": True} + + return TestClient(app), db + + +def _token_for(user: User) -> str: + return issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email}) + + +def test_route_allows_user_with_permission(app_and_db): + client, db = app_and_db + craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one() + + response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {_token_for(craig)}"}) + assert response.status_code == 200 + + +def test_route_denies_user_without_permission(app_and_db): + client, db = app_and_db + ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one() + + response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {_token_for(ops)}"}) + assert response.status_code == 403 + assert "edit_raw_materials" in response.json()["detail"] + + +def test_route_denies_inactive_user(app_and_db): + client, db = app_and_db + craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one() + craig.is_active = False + db.commit() + + response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {_token_for(craig)}"}) + assert response.status_code == 403 + + +def test_route_denies_missing_token(app_and_db): + client, _ = app_and_db + response = client.get("/needs-edit-raw") + assert response.status_code == 401 + + +def test_route_denies_token_with_wrong_subject(app_and_db): + client, db = app_and_db + craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one() + forged = issue_token({"sub": "client", "user_id": craig.id}) + + response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {forged}"}) + assert response.status_code == 401 + + +def test_route_denies_unknown_user_id(app_and_db): + client, _ = app_and_db + forged = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": 999_999}) + + response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {forged}"}) + assert response.status_code == 401 + + +def test_require_any_permission_passes_with_one_match(app_and_db): + client, db = app_and_db + craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one() # has edit_raw_materials + + response = client.get("/needs-any", headers={"Authorization": f"Bearer {_token_for(craig)}"}) + assert response.status_code == 200 + + +def test_require_any_permission_denies_when_none_match(app_and_db): + client, db = app_and_db + ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one() + + response = client.get("/needs-any", headers={"Authorization": f"Bearer {_token_for(ops)}"}) + assert response.status_code == 403 + + +def test_require_all_permissions(app_and_db): + client, db = app_and_db + admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one() + ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one() + + ok = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(admin)}"}) + assert ok.status_code == 200 + + denied = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(ops)}"}) + assert denied.status_code == 403 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d75eff..879fdef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "data-entry-app-frontend", "version": "0.1.5", + "dependencies": { + "lucide-svelte": "^1.0.1" + }, "devDependencies": { "@sveltejs/adapter-auto": "^3.2.0", "@sveltejs/adapter-node": "^5.2.12", @@ -55,7 +58,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -66,7 +68,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -77,7 +78,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -87,14 +87,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -882,7 +880,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1020,7 +1017,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/resolve": { @@ -1034,7 +1030,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@vitest/expect": { @@ -1154,7 +1149,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1167,7 +1161,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1187,7 +1180,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1207,7 +1199,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1261,7 +1252,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", - "dev": true, "license": "MIT" }, "node_modules/es-errors": { @@ -1285,14 +1275,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1420,7 +1408,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -1701,14 +1688,21 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, + "node_modules/lucide-svelte": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", + "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1988,7 +1982,6 @@ "version": "5.55.5", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -2298,7 +2291,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/frontend/package.json b/frontend/package.json index 2a658e2..2d0e497 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,5 +17,8 @@ "typescript": "^5.5.4", "vite": "^8.0.0", "vitest": "^4.0.0" + }, + "dependencies": { + "lucide-svelte": "^1.0.1" } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 69ae4c5..1074eed 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -14,6 +14,7 @@ import { import type { ClientAccessAccount, ClientAccessPowerBiExport, + DashboardSummary, ClientUserCreateInput, ClientUserModulePermission, ClientUserUpdateInput, @@ -125,6 +126,62 @@ async function fetchJson(path: string, fallback: T, auth: AuthMode = 'none', } } +// In-memory GET cache with TTL + in-flight de-duplication. The cache key +// includes the auth-mode and last 8 chars of the bearer token so different +// sessions can't read each other's entries. Any mutation calls clearApiCache() +// to invalidate. Memory footprint is bounded by entries naturally aging out. +type CacheEntry = { value: unknown; expiresAt: number }; +const responseCache = new Map(); +const inflightRequests = new Map>(); +const READ_CACHE_TTL_MS = 30_000; + +function makeCacheKey(path: string, auth: AuthMode) { + const token = browser ? getToken(auth) ?? '' : ''; + return `${auth}:${token.slice(-8)}:${path}`; +} + +async function cachedFetchJson( + path: string, + fallback: T, + auth: AuthMode = 'none', + fetcher: ApiFetch = fetch +): Promise { + // Bypass the cache during SSR (no localStorage, no shared session). + if (!browser) { + return fetchJson(path, fallback, auth, fetcher); + } + + const key = makeCacheKey(path, auth); + const now = Date.now(); + const cached = responseCache.get(key); + if (cached && cached.expiresAt > now) { + return cached.value as T; + } + + // De-duplicate concurrent callers (e.g. two effects firing the same load). + const existing = inflightRequests.get(key); + if (existing) { + return existing as Promise; + } + + const promise = fetchJson(path, fallback, auth, fetcher) + .then((value) => { + responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS }); + return value; + }) + .finally(() => { + inflightRequests.delete(key); + }); + + inflightRequests.set(key, promise); + return promise; +} + +export function clearApiCache() { + responseCache.clear(); + inflightRequests.clear(); +} + async function request( path: string, options: RequestInit, @@ -155,6 +212,12 @@ async function request( throw new Error(message); } + const isMutation = !!options.method && options.method.toUpperCase() !== 'GET'; + if (isMutation && browser) { + // Mutations invalidate cached reads — keeps Dashboard / lists fresh + // after the user creates or updates anything. + clearApiCache(); + } return (await response.json()) as T; } catch (error) { throw normalizeRequestError(error); @@ -162,13 +225,13 @@ async function request( } export const api = { - rawMaterials: (fetcher?: ApiFetch) => fetchJson('/api/raw-materials', mockRawMaterials, 'client', fetcher), - mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher), + rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson('/api/raw-materials', mockRawMaterials, 'client', fetcher), + mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher), mix: (mixId: number, fetcher?: ApiFetch) => request(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher), mixCalculatorOptions: (fetcher?: ApiFetch) => - fetchJson('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher), + cachedFetchJson('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher), mixCalculatorSessions: (fetcher?: ApiFetch) => - fetchJson('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher), + cachedFetchJson('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher), mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) => request(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher), previewMixCalculatorSession: (payload: MixCalculatorCreateInput) => @@ -186,19 +249,41 @@ export const api = { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), - products: (fetcher?: ApiFetch) => fetchJson('/api/products', mockProducts, 'client', fetcher), + products: (fetcher?: ApiFetch) => cachedFetchJson('/api/products', mockProducts, 'client', fetcher), productCosts: (fetcher?: ApiFetch) => - fetchJson('/api/powerbi/product-costs', mockCosts, 'client', fetcher), - scenarios: (fetcher?: ApiFetch) => fetchJson('/api/scenarios', mockScenarios, 'client', fetcher), - clientAccess: (fetcher?: ApiFetch) => fetchJson('/api/client-access', mockClientAccess, 'manager', fetcher), + cachedFetchJson('/api/powerbi/product-costs', mockCosts, 'client', fetcher), + scenarios: (fetcher?: ApiFetch) => cachedFetchJson('/api/scenarios', mockScenarios, 'client', fetcher), + clientAccess: (fetcher?: ApiFetch) => cachedFetchJson('/api/client-access', mockClientAccess, 'manager', fetcher), clientAccessExport: (fetcher?: ApiFetch) => - fetchJson('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher), - dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher), + cachedFetchJson('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher), + dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher), + dashboardSummary: (fetcher?: ApiFetch) => + cachedFetchJson( + '/api/dashboard/summary', + { + raw_materials: null, + mixes: null, + products: null, + trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] } + }, + 'client', + fetcher + ), clientLogin: (email: string, password: string) => request('/api/auth/client/login', { method: 'POST', body: JSON.stringify({ email, password }) }), + // Internal Hunter Stock Feeds login. Returns the same LoginResponse shape + // (with `permissions` populated) so the existing client-session store can + // consume it directly. + internalLogin: (email: string, password: string) => + request('/api/access/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }), + internalSession: (fetcher?: ApiFetch) => + request('/api/access/me', { method: 'GET' }, 'client', fetcher), adminLogin: (email: string, password: string) => request('/api/auth/admin/login', { method: 'POST', diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index 1c60750..172ef2c 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -7,6 +7,24 @@ import { featureFlags } from '$lib/features'; import { onMount, tick } from 'svelte'; import packageInfo from '../../../package.json'; + import { + LayoutDashboard, + Calculator, + Wheat, + FlaskConical, + Boxes, + Workflow, + ShieldCheck, + DollarSign, + ClipboardList, + Search, + LogOut, + Plus, + Settings, + ChevronDown, + Menu + } from 'lucide-svelte'; + import type { ComponentType } from 'svelte'; type SearchItem = { href: string; @@ -19,29 +37,31 @@ href: string; label: string; shortLabel: string; - icon?: 'home'; + icon: ComponentType; moduleKey?: string; }; - const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' }; + const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: LayoutDashboard, moduleKey: 'dashboard' }; const mixCalculatorItem: NavItem = { href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new', label: 'Mix Calculator', shortLabel: 'MC', + icon: Calculator, moduleKey: 'mix_calculator' }; const workingDocumentItems: NavItem[] = [ - { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' }, - { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' }, - { href: '/products', label: 'Products', shortLabel: 'PR', moduleKey: 'products' }, - { href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' } + { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' }, + { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' }, + { href: '/products', label: 'Products', shortLabel: 'PR', icon: Boxes, moduleKey: 'products' }, + { href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', icon: Workflow, moduleKey: 'scenarios' } ]; - const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', moduleKey: 'client_access' }; + const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', icon: ShieldCheck, moduleKey: 'client_access' }; const navigation = [dashboardItem, mixCalculatorItem, ...workingDocumentItems, accessControlItem]; - const footerLinks = [ - { href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' }, - { href: '/scenarios', label: 'Planning View', shortLabel: 'PV' } + type FooterLink = { href: string; label: string; shortLabel: string; icon: ComponentType }; + const footerLinks: FooterLink[] = [ + { href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign }, + { href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList } ]; const baseSearchItems: SearchItem[] = [ @@ -140,8 +160,8 @@ ...footerLinks, ...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage') ? [] - : [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }]) - ]); + : [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }]) + ] as FooterLink[]); const primaryBottomNavigation = $derived( [ ...(visibleDashboardItem ? [visibleDashboardItem] : []), @@ -162,25 +182,44 @@ return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard'; } - function pageDescription(pathname: string) { - if (pathname.startsWith('/mix-calculator/')) { - return 'Review a saved mix calculation session and prepare a printable output'; + type Crumb = { label: string; href?: string }; + + // Breadcrumbs replace the previous descriptive subtitle. They give every + // page the same shape — Workspace › Section › Subpage — so headings feel + // consistent across the app instead of each page improvising its own copy. + function breadcrumbs(pathname: string): Crumb[] { + const root: Crumb = { label: 'Workspace', href: '/' }; + + if (pathname === '/') { + return [root, { label: 'Dashboard' }]; } - const descriptions: Record = { - '/': 'Hunter Premium Produce client workspace', - '/raw-materials': 'Review source input costs and downstream exposure', - '/mixes': 'Browse saved mix worksheets and costing outputs', - '/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce', - '/mix-calculator': 'Create and review client-specific mix calculation sessions', - '/mix-calculator/new': 'Create a new client-specific mix calculation session', - '/products': 'Track delivered product pricing and margin views', - '/settings': 'Review your workspace profile and application settings', - '/scenarios': 'Compare alternate pricing and production assumptions', - '/client-access': 'Manage user access, module permissions, and audit history' - }; + if (pathname.startsWith('/mix-calculator')) { + const trail: Crumb[] = [root, { label: 'Mix Calculator', href: '/mix-calculator' }]; + if (pathname === '/mix-calculator/new') trail.push({ label: 'New Session' }); + else if (pathname.endsWith('/print')) trail.push({ label: 'Print' }); + else if (pathname !== '/mix-calculator') trail.push({ label: 'Session' }); + return trail; + } - return descriptions[pathname] ?? 'Hunter Premium Produce client workspace'; + if (pathname.startsWith('/mixes')) { + const trail: Crumb[] = [root, { label: 'Mix Master', href: '/mixes' }]; + if (pathname === '/mixes/new') trail.push({ label: 'New Mix' }); + else if (pathname !== '/mixes') trail.push({ label: 'Detail' }); + return trail; + } + + const sectionMap: Record = { + '/raw-materials': 'Raw Materials', + '/products': 'Products', + '/scenarios': 'Scenarios', + '/client-access': 'Client Access', + '/settings': 'Settings' + }; + const section = sectionMap[pathname]; + if (section) return [root, { label: section }]; + + return [root, { label: pageTitle(pathname) }]; } function openPalette(query = '') { @@ -255,10 +294,16 @@ restoredToken = token; isRestoringSession = true; - api.clientSession() + // Internal Hunter Stock Feeds users are refreshed against /api/access/me; + // legacy client-portal users keep using /api/auth/client/session. + const refresh = $clientSession?.role === 'internal' ? api.internalSession() : api.clientSession(); + + refresh .then((session) => { - restoredToken = session.token; - clientSession.set(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 }); return invalidateAll(); }) .catch(() => { @@ -270,10 +315,14 @@ }); }); + // Search palette items are seeded lazily — three list endpoints worth of + // data only when the user actually opens the palette, not on every login or + // navigation. Subsequent opens hit the api.ts cache. $effect(() => { const hydrated = $sessionHydrated; const session = $clientSession; const token = session?.token ?? null; + const shouldSeed = paletteOpen; if (!hydrated || !session || !token) { seededSearchItems = []; @@ -281,7 +330,7 @@ return; } - if (seededSearchToken === token) { + if (!shouldSeed || seededSearchToken === token) { return; } @@ -398,90 +447,65 @@ @@ -491,8 +515,17 @@
+

{pageTitle(page.url.pathname)}

-

{pageDescription(page.url.pathname)}

@@ -599,42 +632,15 @@ {#if showBottomNav} @@ -668,97 +674,58 @@
(navOpen = false)}> - NW + Create mix worksheet (navOpen = false)}> - MC + Create mix session (navOpen = false)}> - DP + Review delivered pricing {#if $clientSession} {/if} @@ -871,7 +838,7 @@ .app-shell { display: grid; - grid-template-columns: 244px minmax(0, 1fr); + grid-template-columns: 252px minmax(0, 1fr); min-height: 100vh; } @@ -891,14 +858,15 @@ .sidebar { display: flex; flex-direction: column; - gap: 1.1rem; - padding: 0.9rem; + gap: 0.4rem; + padding: 1.1rem 0.85rem 0.85rem; background: var(--panel); border-right: 1px solid var(--line); position: sticky; top: 0; height: 100vh; - overflow: hidden; + overflow-y: auto; + scrollbar-width: thin; } .sidebar-body { @@ -906,7 +874,20 @@ display: flex; flex: 1; flex-direction: column; - gap: 1.1rem; + gap: 0.45rem; + } + + .nav-section-label { + margin: 0.85rem 0.55rem 0.3rem; + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + } + + .nav-section-label:first-child { + margin-top: 0.25rem; } .brand-row { @@ -914,6 +895,8 @@ align-items: center; justify-content: space-between; gap: 0.68rem; + padding: 0 0.25rem 0.4rem; + border-bottom: 1px solid var(--line); } .brand { @@ -926,11 +909,21 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: #fff; - background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%); + color: #6d7d74; + background: transparent; + border-radius: 0.55rem; + width: 1.6rem; + height: 1.6rem; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; + transition: color 140ms ease, background-color 140ms ease; + } + + .nav-list a:hover .nav-icon, + .nav-list a.active .nav-icon, + .sidebar-signout:hover .nav-icon { + color: var(--green-deep); } .nav-icon-mask { @@ -1047,39 +1040,45 @@ font-size: 0.76rem; } - .nav-list, - .nav-sublist, - .sidebar-footer { + .nav-list { display: grid; - gap: 0.3rem; + gap: 0.12rem; } - .nav-list a, - .nav-sublist a, - .sidebar-footer a { + .nav-list a { + position: relative; display: flex; align-items: center; - gap: 0.68rem; - padding: 0.72rem 0.68rem; - border-radius: 0.82rem; - color: #304038; - transition: background-color 160ms ease; + gap: 0.7rem; + padding: 0.6rem 0.6rem; + border-radius: 0.7rem; + color: #3a4a41; + font-size: 0.93rem; + transition: background-color 140ms ease, color 140ms ease; } - .nav-list a:hover, - .nav-sublist a:hover, - .sidebar-footer a:hover, - .nav-list a.active, - .nav-sublist a.active { + .nav-list a:hover { + background: rgba(234, 248, 239, 0.55); + color: var(--green-deep); + } + + .nav-list a.active { background: var(--green-soft); - } - - .nav-list a.active, - .nav-sublist a.active { color: var(--green-deep); font-weight: 600; } + .nav-list a.active::before { + content: ''; + position: absolute; + left: -0.85rem; + top: 0.45rem; + bottom: 0.45rem; + width: 3px; + border-radius: 999px; + background: var(--green-deep); + } + .nav-group { display: grid; gap: 0.55rem; @@ -1138,45 +1137,62 @@ position: relative; } - .nav-icon { - width: 1.56rem; - height: 1.56rem; - border-radius: 0.56rem; - } - - .nav-icon svg, .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; background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%); } - .sidebar-footer { - margin-top: auto; - padding-top: 0.6rem; - flex-shrink: 0; - } - .sidebar-meta { + margin-top: auto; display: grid; - gap: 0.2rem; - padding: 0.85rem 0.3rem 0; - color: var(--muted); - font-size: 0.78rem; + gap: 0.55rem; + padding-top: 0.85rem; flex-shrink: 0; } - .sidebar-meta small { - font-size: 0.74rem; + .sidebar-signout { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.6rem 0.6rem; + border: none; + border-radius: 0.7rem; + background: transparent; + color: #3a4a41; + font-size: 0.93rem; + text-align: left; + cursor: pointer; + transition: background-color 140ms ease, color 140ms ease; } - .sidebar-version-row { + .sidebar-signout:hover { + background: rgba(234, 248, 239, 0.55); + color: var(--green-deep); + } + + .sidebar-meta-foot { + display: grid; + gap: 0.25rem; + padding: 0.7rem 0.55rem 0; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.76rem; + } + + .sidebar-meta-foot small { + font-size: 0.72rem; + } + + .version-pill { display: inline-flex; align-items: center; - gap: 0.5rem; + gap: 0.45rem; flex-wrap: wrap; } @@ -1218,20 +1234,40 @@ gap: 0.82rem; } - .topbar-copy h1, - .topbar-copy p { - margin: 0; - } - .topbar-copy h1 { - font-size: 1.62rem; + margin: 0.18rem 0 0; + font-size: 1.5rem; font-weight: 700; + letter-spacing: -0.01em; } - .topbar-copy p { - margin-top: 0.22rem; + .breadcrumbs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; color: var(--muted); - font-size: 0.92rem; + font-size: 0.78rem; + font-weight: 500; + } + + .breadcrumbs a { + color: var(--muted); + transition: color 140ms ease; + } + + .breadcrumbs a:hover { + color: var(--green-deep); + } + + .breadcrumbs span[aria-current='page'] { + color: var(--text); + font-weight: 600; + } + + .breadcrumb-sep { + color: #b9c5be; + font-size: 0.78rem; } .topbar-middle { diff --git a/frontend/src/lib/components/MixCalculatorWorkspace.svelte b/frontend/src/lib/components/MixCalculatorWorkspace.svelte index 537c442..77ba7c1 100644 --- a/frontend/src/lib/components/MixCalculatorWorkspace.svelte +++ b/frontend/src/lib/components/MixCalculatorWorkspace.svelte @@ -220,24 +220,16 @@ {/if} {:else} -
-
-

- - Mix Calculator -

-

{isExistingSession ? 'Edit saved mix session' : 'New mix calculation session'}

-

Scale a saved product mix by batch size, review required raw materials, then save the session for history and printing.

-
-
+ {#if featureFlags.mixCalculatorSessionHistory || initialSession} +
{#if featureFlags.mixCalculatorSessionHistory} Session history {/if} {#if initialSession} Printable view {/if} -
-
+ + {/if}
@@ -556,11 +548,18 @@ mask: var(--button-icon-url) center / contain no-repeat; } - .page-intro, + .page-actions, .workspace-grid { margin-bottom: 1.2rem; } + .page-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + flex-wrap: wrap; + } + .page-intro { display: flex; align-items: flex-start; diff --git a/frontend/src/lib/components/Skeleton.svelte b/frontend/src/lib/components/Skeleton.svelte new file mode 100644 index 0000000..84167e2 --- /dev/null +++ b/frontend/src/lib/components/Skeleton.svelte @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts index 2df078b..455e2af 100644 --- a/frontend/src/lib/session.ts +++ b/frontend/src/lib/session.ts @@ -11,6 +11,10 @@ export type AppSession = { user_id?: number | null; client_account_id?: number | null; module_permissions?: Record; + // Permission-key array, populated when the user signed in via the internal + // Hunter Stock Feeds /api/access/login endpoint. Drives feature gating. + permissions?: string[]; + role_name?: string | null; }; const ACCESS_LEVEL_ORDER: Record = { @@ -63,6 +67,9 @@ function createSessionStore(storageKey: string) { clear() { if (browser) { localStorage.removeItem(storageKey); + // Drop any cached API responses keyed to the old session token. + // Imported lazily so this module stays free of api.ts side-effects. + import('$lib/api').then(({ clearApiCache }) => clearApiCache()).catch(() => {}); } store.set(null); } @@ -102,6 +109,23 @@ export function hasModuleAccess( return (ACCESS_LEVEL_ORDER[currentLevel] ?? 0) >= ACCESS_LEVEL_ORDER[minimumLevel]; } +// Permission-key check for the internal access-control system. Returns false +// for legacy sessions that don't carry a permissions array. UI gating only — +// every privileged backend route still enforces permissions itself. +export function hasPermission(session: AppSession | null | undefined, permissionKey: string) { + if (!session?.permissions) { + return false; + } + return session.permissions.includes(permissionKey); +} + +export function hasAnyPermission(session: AppSession | null | undefined, permissionKeys: string[]) { + if (!session?.permissions) { + return false; + } + return permissionKeys.some((key) => session.permissions!.includes(key)); +} + export const sessionHydrated = readable(false, (set) => { if (!browser) { return undefined; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 81bef8d..0a47641 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -298,6 +298,38 @@ export type ClientAccessPowerBiExport = { clients: ClientAccessAccount[]; }; +export type DashboardSummary = { + raw_materials: { + count: number; + total_market_value: number; + latest: { id: number; name: string; market_value: number; cost_per_kg: number; effective_date: string | null } | null; + } | null; + mixes: { + count: number; + average_cost_per_kg: number; + top: { + id: number; + name: string; + client_name: string; + ingredients_count: number; + total_mix_kg: number; + total_mix_cost: number; + mix_cost_per_kg: number | null; + warnings: string[]; + } | null; + } | null; + products: { + count: number; + top: { id: number; product_name: string; client_name: string; finished_product_delivered: number; warnings: string[] } | null; + top_products: Array<{ id: number; product_name: string; client_name: string; finished_product_delivered: number; warnings: string[] }>; + } | null; + trend_seeds: { + raw_material_cost_per_kg: number[]; + mix_cost_per_kg: number[]; + product_finished_delivered: number[]; + }; +}; + export type LoginResponse = { name: string; email: string; @@ -308,6 +340,11 @@ export type LoginResponse = { user_id?: number | null; client_account_id?: number | null; module_permissions?: Record; + // Permission-key array populated by the internal Hunter Stock Feeds login. + // Drives permission-based UI gating; legacy client logins leave it undefined. + permissions?: string[]; + // Display-friendly role label (e.g. "Admin", "Operations") when role === 'internal'. + role_name?: string | null; }; export type RawMaterialCreateInput = { diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index c47ff4c..dd45cba 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,8 +1,10 @@ {#if !$sessionHydrated} @@ -403,40 +401,41 @@
{:else}
-
-
-

Client Workspace

- {releaseStage} -
-

{greetingForAst()}, {firstName($clientSession?.name)}

-

Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.

+
+ {#snippet greetIcon()} + {@const Icon = greeting.icon} + + {/snippet} + {@render greetIcon()} +

{greeting.label}, {firstName($clientSession?.name)}

-
-
-
-

Account

-

Hunter Premium Produce

-

Lean 101 powers the client workspace while operator-only administration now lives in the separate `/admin` area.

-
- -
- {#each focusCards as card} -
- {card.code} -
+
+ {#each focusCards as card, i} +
+ {card.code} +
+ {#if loading} + + + {:else} {card.label} {card.detail} -
+ {/if} +
+ {#if loading} + + {:else} {card.value} -
- {/each} -
+ {/if} + + {/each}
@@ -451,13 +450,21 @@
-

{featuredMaterial?.name ?? 'No material loaded'}

-

{formatDate(featuredMaterial?.current_price?.effective_date)}

-
{currency(featuredMaterial?.current_price?.market_value)}
-

- {currency(featuredMaterial?.current_price?.cost_per_kg, 4)} / kg - Current blend for Hunter Premium Produce -

+ {#if loading} + +
+ +
+ + {:else} +

{featuredMaterial?.name ?? 'No material loaded'}

+

{formatDate(featuredMaterial?.effective_date)}

+
{currency(featuredMaterial?.market_value)}
+

+ {currency(featuredMaterial?.cost_per_kg, 4)} / kg + Current blend for Hunter Premium Produce +

+ {/if}
- {currency(totalMarketValue)} + {#if loading} + + {:else} + {currency(totalMarketValue)} + {/if}

Across all tracked raw materials

@@ -521,7 +532,11 @@ Average Mix Cost
- {currency(averageMixCost, 4)} + {#if loading} + + {:else} + {currency(averageMixCost, 4)} + {/if}

Per kg across the current mix set

@@ -530,8 +545,13 @@ Top Delivered Output
- {currency(featuredProduct?.finished_product_delivered)} -

{featuredProduct?.product_name ?? 'No products loaded'}

+ {#if loading} + +

+ {:else} + {currency(featuredProduct?.finished_product_delivered)} +

{featuredProduct?.product_name ?? 'No products loaded'}

+ {/if}
@@ -599,7 +619,7 @@
Ingredients - {featuredMix?.ingredients.length ?? 0} + {featuredMix?.ingredients_count ?? 0}
@@ -996,12 +1016,30 @@ .dashboard-intro, .workspace-banner, + .focus-row, .dashboard-grid, .analysis-grid, .detail-grid { margin-bottom: 1.25rem; } + .focus-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; + } + + @media (max-width: 1120px) { + .focus-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + } + @media (max-width: 720px) { + .focus-row { grid-template-columns: 1fr; } + } + + .dashboard-intro h2 { + font-size: clamp(1.4rem, 2.4vw, 1.85rem); + } + .dashboard-intro, .card-toolbar, .metric-head, @@ -1020,15 +1058,28 @@ align-items: flex-end; } - .dashboard-intro h2, - .workspace-banner h3 { + .dashboard-intro h2 { margin: 0.3rem 0 0.35rem; font-size: clamp(1.8rem, 3vw, 2.35rem); font-weight: 700; } - .dashboard-intro p:last-child, - .workspace-banner p:last-child, + .greeting-row { + display: flex; + align-items: center; + gap: 1rem; + } + + .greeting-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + color: var(--text); + flex-shrink: 0; + } + .card-toolbar p, .metric-card p, .preview-header p, diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts index 45d235e..366b3c1 100644 --- a/frontend/src/routes/+page.ts +++ b/frontend/src/routes/+page.ts @@ -1,42 +1,38 @@ -import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session'; import { api } from '$lib/api'; +import { getStoredClientSession, hasStoredClientSession } from '$lib/session'; +import type { DashboardSummary } from '$lib/types'; -export async function load({ fetch }) { +const EMPTY_SUMMARY: DashboardSummary = { + raw_materials: null, + mixes: null, + products: null, + trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] } +}; + +// Streaming load: the route shell paints immediately and the dashboard fills +// in once `summary` resolves. This replaces the previous load that awaited +// five separate full collections (raw materials, mixes, all product cost +// breakdowns, scenarios, data-quality) before SvelteKit would render anything. +export function load({ fetch }) { if (!hasStoredClientSession()) { - return { - rawMaterials: [], - mixes: [], - productCosts: [], - scenarios: [], - dataQuality: [] - }; + 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(); - - try { - const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = 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.productCosts(fetch) : Promise.resolve([]), - hasModuleAccess(session, 'scenarios') ? api.scenarios(fetch) : Promise.resolve([]), - hasModuleAccess(session, 'dashboard') ? api.dataQuality(fetch) : Promise.resolve([]) - ]); - - return { - rawMaterials, - mixes, - productCosts, - scenarios, - dataQuality - }; - } catch { - return { - rawMaterials: [], - mixes: [], - productCosts: [], - scenarios: [], - dataQuality: [] - }; + 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) }; } + + return { + summary: api.dashboardSummary(fetch).catch(() => EMPTY_SUMMARY) + }; } diff --git a/frontend/src/routes/client-access/+page.svelte b/frontend/src/routes/client-access/+page.svelte index 9b51726..f5270c4 100644 --- a/frontend/src/routes/client-access/+page.svelte +++ b/frontend/src/routes/client-access/+page.svelte @@ -151,14 +151,6 @@ const previewJson = $derived(JSON.stringify(exportPreview, null, 2)); -
-
-

Client Amend Area

-

Control new users, existing users, and every feature flag in one operational workspace.

-

The preview shows the live Power BI export payload after each amendment so the admin surface and reporting output stay aligned.

-
-
-
Total Clients diff --git a/frontend/src/routes/mix-calculator/+page.svelte b/frontend/src/routes/mix-calculator/+page.svelte index 4bb1041..c4f1493 100644 --- a/frontend/src/routes/mix-calculator/+page.svelte +++ b/frontend/src/routes/mix-calculator/+page.svelte @@ -18,16 +18,11 @@ } -
-
-

Mix Calculator

-

Saved production sessions

-

Each session preserves the scaled raw material output so it can be reopened or printed later without relying on live recipe changes.

-
- {#if canEdit} +{#if canEdit} +
New mix session - {/if} -
+
+{/if}
@@ -123,27 +118,17 @@ text-transform: uppercase; } - .page-intro, + .page-actions, .metric-row, .table-card { margin-bottom: 1.25rem; } - .page-intro { + .page-actions { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; + justify-content: flex-end; } - .page-intro h2 { - margin: 0.35rem 0 0.45rem; - max-width: 15ch; - font-size: clamp(1.7rem, 3vw, 2.2rem); - font-weight: 700; - } - - .page-intro p:last-child, .metric-card p, .table-toolbar p, tbody span { @@ -283,7 +268,6 @@ } @media (max-width: 760px) { - .page-intro, .table-toolbar { flex-direction: column; align-items: flex-start; diff --git a/frontend/src/routes/mixes/+page.svelte b/frontend/src/routes/mixes/+page.svelte index 3c02e8b..0da1046 100644 --- a/frontend/src/routes/mixes/+page.svelte +++ b/frontend/src/routes/mixes/+page.svelte @@ -104,16 +104,8 @@ }); -
-
-

Mix Master

-

Saved mixes in a clean table view.

-

Use the table to browse mixes, then open a dedicated worksheet page to edit or create a formulation.

-
- - +
+ New Mix Worksheet
@@ -221,13 +213,17 @@ text-transform: uppercase; } - .page-intro, + .page-actions, .metric-row, .table-card { margin-bottom: 1.12rem; } - .page-intro, + .page-actions { + display: flex; + justify-content: flex-end; + } + .metric-card, .table-card { background: var(--panel); diff --git a/frontend/src/routes/products/+page.svelte b/frontend/src/routes/products/+page.svelte index b256043..ad9f6ce 100644 --- a/frontend/src/routes/products/+page.svelte +++ b/frontend/src/routes/products/+page.svelte @@ -46,14 +46,6 @@ ); -
-
-

Output Pricing

-

Delivered product pricing

-

Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.

-
-
-
Total Products diff --git a/frontend/src/routes/raw-materials/+page.svelte b/frontend/src/routes/raw-materials/+page.svelte index cb94720..c9b3ef9 100644 --- a/frontend/src/routes/raw-materials/+page.svelte +++ b/frontend/src/routes/raw-materials/+page.svelte @@ -152,19 +152,6 @@ Return to sign-in
{:else} -
-
-

Input Cost Control

-

Maintain raw materials with a cleaner operational workflow.

-

Update source pricing, track downstream exposure, and keep the costing engine current from one workspace.

-
- -
- {$clientSession.email} - {activeMaterials.length} active materials -
-
- {#if successMessage} {/if} diff --git a/frontend/src/routes/scenarios/+page.svelte b/frontend/src/routes/scenarios/+page.svelte index f6232d1..1115a1f 100644 --- a/frontend/src/routes/scenarios/+page.svelte +++ b/frontend/src/routes/scenarios/+page.svelte @@ -11,14 +11,6 @@ const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length); -
-
-

Scenario Sandbox

-

Simulation workspaces with a cleaner review and comparison layer.

-

Scenarios now read like structured operating plans instead of raw debug output.

-
-
-
Total Scenarios diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 1843c14..249b1c7 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -4,14 +4,6 @@ const currentYear = new Date().getFullYear(); -
-
-

Workspace Settings

-

Account and workspace preferences.

-

Review your current session, navigation setup, and the client workspace details shown across the app.

-
-
-

Session