Move working documents to its own area, rename dashboard

This commit is contained in:
2026-04-29 01:21:16 +12:00
parent 7e9663fa06
commit 761ebb050d
32 changed files with 1779 additions and 526 deletions
+66 -11
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -8,6 +8,7 @@ from app.core.config import settings
from app.core.security import issue_token from app.core.security import issue_token
from app.db.session import get_db from app.db.session import get_db
from app.models.client_access import ClientAccount 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"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -22,28 +23,70 @@ class SessionResponse(BaseModel):
email: str email: str
role: str role: str
tenant_id: str | None = None tenant_id: str | None = None
client_role: str | None = None
user_id: int | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] = Field(default_factory=dict)
token: str token: str
def _build_session_response(*, name: str, email: str, role: str, tenant_id: str | None = None) -> SessionResponse: def _build_session_response(
token = issue_token({"name": name, "email": email, "role": role, "tenant_id": tenant_id}) *,
return SessionResponse(name=name, email=email, role=role, tenant_id=tenant_id, token=token) name: str,
email: str,
role: str,
tenant_id: str | None = None,
client_role: str | None = None,
user_id: int | None = None,
client_account_id: int | None = None,
module_permissions: dict[str, str] | None = None,
) -> SessionResponse:
token = issue_token(
{
"name": name,
"email": email,
"role": role,
"tenant_id": tenant_id,
"client_role": client_role,
"user_id": user_id,
"client_account_id": client_account_id,
}
)
return SessionResponse(
name=name,
email=email,
role=role,
tenant_id=tenant_id,
client_role=client_role,
user_id=user_id,
client_account_id=client_account_id,
module_permissions=module_permissions or {},
token=token,
)
@router.post("/client/login", response_model=SessionResponse) @router.post("/client/login", response_model=SessionResponse)
def client_login(payload: LoginRequest, db: Session = Depends(get_db)): def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
if payload.email.strip().lower() != settings.client_email.lower() or payload.password != settings.client_password: if payload.password != settings.client_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
client_account = db.scalar(select(ClientAccount).where(ClientAccount.tenant_id == settings.client_tenant_id)) user = get_client_user_by_email(db, email=payload.email.strip().lower())
if user is None:
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: if client_account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured for this user")
return _build_session_response( return _build_session_response(
name=settings.client_name, name=user.full_name,
email=settings.client_email, email=user.email,
role="client", role="client",
tenant_id=client_account.tenant_id, tenant_id=client_account.tenant_id,
client_role=user.role,
user_id=user.id,
client_account_id=client_account.id,
module_permissions=module_access_map(user),
) )
@@ -56,8 +99,20 @@ def admin_login(payload: LoginRequest):
@router.get("/client/session", response_model=SessionResponse) @router.get("/client/session", response_model=SessionResponse)
def read_client_session(session: AuthSession = Depends(require_client_session)): def read_client_session(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
return _build_session_response(name=session.name, email=session.email, role=session.role, tenant_id=session.tenant_id) user = get_client_user_by_email(db, email=session.email, tenant_id=session.tenant_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client user session is no longer available")
return _build_session_response(
name=user.full_name,
email=user.email,
role=session.role,
tenant_id=session.tenant_id,
client_role=user.role,
user_id=user.id,
client_account_id=user.client_account_id,
module_permissions=module_access_map(user),
)
@router.get("/admin/session", response_model=SessionResponse) @router.get("/admin/session", response_model=SessionResponse)
+168 -22
View File
@@ -1,45 +1,111 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, selectinload
from app.api.deps import require_admin_session from app.api.deps import AuthSession, require_client_access_manager_session
from app.db.session import get_db from app.db.session import get_db
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.schemas.client_access import ClientAccessRead, ClientFeatureUpdate, ClientUserCreate, ClientUserUpdate from app.schemas.client_access import (
from app.services.client_access_service import list_client_accounts, serialize_client_account ClientAccessRead,
ClientFeatureUpdate,
ClientUserCreate,
ClientUserModulePermissionUpdate,
ClientUserUpdate,
)
from app.services.client_access_service import (
ACCESS_LEVEL_ORDER,
MODULE_INDEX,
default_access_level_for_role,
ensure_user_module_permissions,
list_client_accounts,
record_audit_event,
serialize_client_account,
)
router = APIRouter(prefix="/api/client-access", tags=["client-access"]) router = APIRouter(prefix="/api/client-access", tags=["client-access"])
def _get_client_or_404(db: Session, client_id: int) -> ClientAccount: def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]:
client = db.scalar(select(ClientAccount).where(ClientAccount.id == client_id)) clients = list_client_accounts(db)
if session.role == "admin":
return clients
return [client for client in clients if client.id == session.client_account_id]
def _get_client_or_404(db: Session, client_id: int, session: AuthSession) -> ClientAccount:
clients = _authorized_client_scope(db, session)
client = next((client for client in clients if client.id == client_id), None)
if client is None: if client is None:
raise HTTPException(status_code=404, detail="Client account not found") raise HTTPException(status_code=404, detail="Client account not found")
return client return client
def _read_client_account(db: Session, client_id: int) -> dict: def _get_user_or_404(db: Session, user_id: int, session: AuthSession) -> ClientUser:
client = next((item for item in list_client_accounts(db) if item.id == client_id), None) user = db.scalar(
select(ClientUser)
.where(ClientUser.id == user_id)
.options(selectinload(ClientUser.module_permissions))
)
if user is None:
raise HTTPException(status_code=404, detail="Client user not found")
_get_client_or_404(db, user.client_account_id, session)
return user
def _read_client_account(db: Session, client_id: int, session: AuthSession) -> dict:
client = next((item for item in _authorized_client_scope(db, session) if item.id == client_id), None)
if client is None: if client is None:
raise HTTPException(status_code=404, detail="Client account not found") raise HTTPException(status_code=404, detail="Client account not found")
return serialize_client_account(client) return serialize_client_account(client)
def _actor_metadata(session: AuthSession) -> dict[str, str]:
if session.role == "admin":
return {
"actor_type": "lean_admin",
"actor_name": session.name,
"actor_email": session.email,
"actor_role": "admin",
}
return {
"actor_type": "client_superadmin",
"actor_name": session.name,
"actor_email": session.email,
"actor_role": session.client_role or "client",
}
@router.get("", response_model=list[ClientAccessRead]) @router.get("", response_model=list[ClientAccessRead])
def get_client_access(db: Session = Depends(get_db), _: object = Depends(require_admin_session)): def get_client_access(
return [serialize_client_account(client) for client in list_client_accounts(db)] 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)]
@router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED) @router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED)
def create_client_user( def create_client_user(
payload: ClientUserCreate, payload: ClientUserCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: object = Depends(require_admin_session), session: AuthSession = Depends(require_client_access_manager_session),
): ):
client = _get_client_or_404(db, payload.client_account_id) client = _get_client_or_404(db, payload.client_account_id, session)
user = ClientUser(tenant_id=client.tenant_id, **payload.model_dump()) user = ClientUser(tenant_id=client.tenant_id, **payload.model_dump())
db.add(user) db.add(user)
db.flush()
ensure_user_module_permissions(db, user)
record_audit_event(
db,
tenant_id=client.tenant_id,
client_account_id=client.id,
action="user.created",
target_type="client_user",
target_id=user.id,
module_key="client_access",
summary=f"{user.full_name} was created with the {user.role} role.",
**_actor_metadata(session),
)
try: try:
db.commit() db.commit()
@@ -47,25 +113,93 @@ def create_client_user(
db.rollback() db.rollback()
raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc
return _read_client_account(db, payload.client_account_id) return _read_client_account(db, payload.client_account_id, session)
@router.patch("/users/{user_id}", response_model=ClientAccessRead) @router.patch("/users/{user_id}", response_model=ClientAccessRead)
def update_client_user(user_id: int, payload: ClientUserUpdate, db: Session = Depends(get_db), _: object = Depends(require_admin_session)): def update_client_user(
user = db.scalar(select(ClientUser).where(ClientUser.id == user_id)) user_id: int,
if user is None: payload: ClientUserUpdate,
raise HTTPException(status_code=404, detail="Client user not found") db: Session = Depends(get_db),
session: AuthSession = Depends(require_client_access_manager_session),
):
user = _get_user_or_404(db, user_id, session)
changes = payload.model_dump(exclude_unset=True)
original_role = user.role
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in changes.items():
setattr(user, field, value) setattr(user, field, value)
if "role" in changes and changes["role"] != original_role:
for permission in user.module_permissions:
permission.access_level = default_access_level_for_role(user.role, permission.module_key)
record_audit_event(
db,
tenant_id=user.tenant_id,
client_account_id=user.client_account_id,
action="user.updated",
target_type="client_user",
target_id=user.id,
module_key="client_access",
summary=f"{user.full_name} access was updated.",
**_actor_metadata(session),
)
try: try:
db.commit() db.commit()
except IntegrityError as exc: except IntegrityError as exc:
db.rollback() db.rollback()
raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc
return _read_client_account(db, user.client_account_id) return _read_client_account(db, user.client_account_id, session)
@router.patch("/users/{user_id}/module-permissions/{module_key}", response_model=ClientAccessRead)
def update_client_user_module_permission(
user_id: int,
module_key: str,
payload: ClientUserModulePermissionUpdate,
db: Session = Depends(get_db),
session: AuthSession = Depends(require_client_access_manager_session),
):
user = _get_user_or_404(db, user_id, session)
if payload.access_level not in ACCESS_LEVEL_ORDER:
raise HTTPException(status_code=422, detail="Invalid access level")
permission = db.scalar(
select(ClientUserModulePermission).where(
ClientUserModulePermission.client_user_id == user.id,
ClientUserModulePermission.module_key == module_key,
)
)
if permission is None:
if module_key not in MODULE_INDEX:
raise HTTPException(status_code=404, detail="Module permission not found")
permission = ClientUserModulePermission(
tenant_id=user.tenant_id,
client_account_id=user.client_account_id,
client_user_id=user.id,
module_key=module_key,
access_level=default_access_level_for_role(user.role, module_key),
)
db.add(permission)
db.flush()
permission.access_level = payload.access_level
module_name = MODULE_INDEX.get(module_key, {}).get("module_name", module_key.replace("_", " ").title())
record_audit_event(
db,
tenant_id=user.tenant_id,
client_account_id=user.client_account_id,
action="module_permission.updated",
target_type="client_user_module_permission",
target_id=permission.id,
module_key=module_key,
summary=f"{user.full_name} now has {payload.access_level} access to {module_name}.",
**_actor_metadata(session),
)
db.commit()
return _read_client_account(db, user.client_account_id, session)
@router.patch("/features/{feature_id}", response_model=ClientAccessRead) @router.patch("/features/{feature_id}", response_model=ClientAccessRead)
@@ -73,12 +207,24 @@ def update_client_feature(
feature_id: int, feature_id: int,
payload: ClientFeatureUpdate, payload: ClientFeatureUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: object = Depends(require_admin_session), session: AuthSession = Depends(require_client_access_manager_session),
): ):
feature = db.scalar(select(ClientFeatureAccess).where(ClientFeatureAccess.id == feature_id)) feature = db.scalar(select(ClientFeatureAccess).where(ClientFeatureAccess.id == feature_id))
if feature is None: if feature is None:
raise HTTPException(status_code=404, detail="Client feature not found") raise HTTPException(status_code=404, detail="Client feature not found")
_get_client_or_404(db, feature.client_account_id, session)
feature.enabled = payload.enabled feature.enabled = payload.enabled
record_audit_event(
db,
tenant_id=feature.tenant_id,
client_account_id=feature.client_account_id,
action="feature.updated",
target_type="client_feature",
target_id=feature.id,
module_key=feature.feature_key,
summary=f"{feature.feature_name} was {'enabled' if payload.enabled else 'disabled'}.",
**_actor_metadata(session),
)
db.commit() db.commit()
return _read_client_account(db, feature.client_account_id) return _read_client_account(db, feature.client_account_id, session)
+92
View File
@@ -4,8 +4,13 @@ from dataclasses import dataclass
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 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.core.security import verify_token
from app.db.session import get_db
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) bearer_scheme = HTTPBearer(auto_error=False)
@@ -16,6 +21,10 @@ class AuthSession:
email: str email: str
name: str name: str
tenant_id: str | None = None tenant_id: str | None = None
client_role: str | None = None
user_id: int | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] | None = None
def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession: def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession:
@@ -28,6 +37,10 @@ def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(
email=str(payload.get("email", "")), email=str(payload.get("email", "")),
name=str(payload.get("name", "")), name=str(payload.get("name", "")),
tenant_id=payload.get("tenant_id"), tenant_id=payload.get("tenant_id"),
client_role=payload.get("client_role"),
user_id=payload.get("user_id"),
client_account_id=payload.get("client_account_id"),
module_permissions={},
) )
@@ -36,6 +49,8 @@ def require_client_session(session: AuthSession = Depends(get_auth_session)) ->
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
if not session.tenant_id: if not session.tenant_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client tenant is missing") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client tenant is missing")
if not session.user_id or not session.client_account_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user context is missing")
return session return session
@@ -43,3 +58,80 @@ def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> A
if session.role != "admin": if session.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return session return session
def load_current_client_user(db: Session, session: AuthSession) -> ClientUser:
user = db.scalar(
select(ClientUser)
.where(
ClientUser.id == session.user_id,
ClientUser.client_account_id == session.client_account_id,
ClientUser.tenant_id == session.tenant_id,
)
.options(selectinload(ClientUser.module_permissions))
)
if user is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user access is no longer valid")
if user.status == "suspended":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client user access is suspended")
return user
def require_client_module_access(module_key: str, minimum_level: str = "view"):
def dependency(
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
) -> AuthSession:
user = load_current_client_user(db, session)
feature = db.scalar(
select(ClientFeatureAccess).where(
ClientFeatureAccess.client_account_id == user.client_account_id,
ClientFeatureAccess.tenant_id == user.tenant_id,
ClientFeatureAccess.feature_key == module_key,
)
)
if feature is not None and not feature.enabled:
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):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted")
return AuthSession(
role=session.role,
email=session.email,
name=session.name,
tenant_id=session.tenant_id,
client_role=user.role,
user_id=user.id,
client_account_id=user.client_account_id,
module_permissions=permissions,
)
return dependency
def require_client_access_manager_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
if session.role == "admin":
return session
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access")
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"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required")
return AuthSession(
role=session.role,
email=session.email,
name=session.name,
tenant_id=session.tenant_id,
client_role=user.role,
user_id=user.id,
client_account_id=user.client_account_id,
module_permissions=permissions,
)
+9 -9
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
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.db.session import get_db
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.raw_material import RawMaterial from app.models.raw_material import RawMaterial
@@ -13,13 +13,13 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"])
@router.get("", response_model=list[MixRead]) @router.get("", response_model=list[MixRead])
def list_mixes(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): 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() mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes] return [calculate_mix_cost(db, mix.id) for mix in mixes]
@router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)):
mix = Mix( mix = Mix(
tenant_id=session.tenant_id, tenant_id=session.tenant_id,
client_name=payload.client_name, client_name=payload.client_name,
@@ -52,14 +52,14 @@ def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client
@router.get("/{mix_id}", response_model=MixRead) @router.get("/{mix_id}", response_model=MixRead)
def get_mix(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_mix(mix_id: int, session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None: if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
return calculate_mix_cost(db, mix_id) return calculate_mix_cost(db, mix_id)
@router.patch("/{mix_id}", response_model=MixRead) @router.patch("/{mix_id}", response_model=MixRead)
def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)):
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None: if mix is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
@@ -70,7 +70,7 @@ def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(r
@router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED) @router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None: if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None: if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
@@ -93,7 +93,7 @@ def update_mix_ingredient(
mix_id: int, mix_id: int,
ingredient_id: int, ingredient_id: int,
payload: MixIngredientUpdate, payload: MixIngredientUpdate,
session: AuthSession = Depends(require_client_session), session: AuthSession = Depends(require_client_module_access("mix_master", "edit")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
ingredient = db.scalar( ingredient = db.scalar(
@@ -112,7 +112,7 @@ def update_mix_ingredient(
@router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead) @router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_module_access("mix_master", "edit")), db: Session = Depends(get_db)):
ingredient = db.scalar( ingredient = db.scalar(
select(MixIngredient).where( select(MixIngredient).where(
MixIngredient.id == ingredient_id, MixIngredient.id == ingredient_id,
@@ -128,7 +128,7 @@ def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession
@router.get("/{mix_id}/cost-breakdown", response_model=MixRead) @router.get("/{mix_id}/cost-breakdown", response_model=MixRead)
def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None: if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
return calculate_mix_cost(db, mix_id) return calculate_mix_cost(db, mix_id)
+14 -8
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_admin_session, require_client_session from app.api.deps import AuthSession, require_client_access_manager_session, require_client_module_access
from app.db.session import get_db from app.db.session import get_db
from app.models.mix import Mix from app.models.mix import Mix
from app.models.product import Product from app.models.product import Product
@@ -15,25 +15,25 @@ router = APIRouter(prefix="/api/powerbi", tags=["powerbi"])
@router.get("/raw-material-costs") @router.get("/raw-material-costs")
def raw_material_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def raw_material_costs(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).order_by(RawMaterial.name)).all() materials = db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == session.tenant_id).order_by(RawMaterial.name)).all()
return [serialize_raw_material(material) for material in materials] return [serialize_raw_material(material) for material in materials]
@router.get("/mix-costs") @router.get("/mix-costs")
def mix_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def mix_costs(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() mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes] return [calculate_mix_cost(db, mix.id) for mix in mixes]
@router.get("/product-costs") @router.get("/product-costs")
def product_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def product_costs(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() products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all()
return [calculate_product_cost(db, product.id) for product in products] return [calculate_product_cost(db, product.id) for product in products]
@router.get("/scenario-results") @router.get("/scenario-results")
def scenario_results(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def scenario_results(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
scenarios = db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all() scenarios = db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
return [ return [
{ {
@@ -48,12 +48,18 @@ def scenario_results(session: AuthSession = Depends(require_client_session), db:
@router.get("/client-access") @router.get("/client-access")
def client_access_export(_: AuthSession = Depends(require_admin_session), db: Session = Depends(get_db)): def client_access_export(
return build_client_access_export(list_client_accounts(db)) session: AuthSession = Depends(require_client_access_manager_session),
db: Session = Depends(get_db),
):
clients = list_client_accounts(db)
if session.role == "client":
clients = [client for client in clients if client.id == session.client_account_id]
return build_client_access_export(clients)
@router.get("/data-quality-issues") @router.get("/data-quality-issues")
def data_quality_issues(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def data_quality_issues(session: AuthSession = Depends(require_client_module_access("dashboard")), db: Session = Depends(get_db)):
issues: list[dict] = [] issues: list[dict] = []
for mix in db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id)).all(): for mix in db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id)).all():
result = calculate_mix_cost(db, mix.id) result = calculate_mix_cost(db, mix.id)
+7 -7
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
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.db.session import get_db
from app.models.mix import Mix from app.models.mix import Mix
from app.models.product import Product from app.models.product import Product
@@ -34,13 +34,13 @@ def _serialize_product(product: Product) -> dict:
@router.get("", response_model=list[ProductRead]) @router.get("", response_model=list[ProductRead])
def list_products(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): 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() products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all()
return [_serialize_product(product) for product in products] return [_serialize_product(product) for product in products]
@router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_module_access("products", "edit")), db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id)) is None: if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
product = Product(tenant_id=session.tenant_id, **payload.model_dump()) product = Product(tenant_id=session.tenant_id, **payload.model_dump())
@@ -51,7 +51,7 @@ def create_product(payload: ProductCreate, session: AuthSession = Depends(requir
@router.get("/{product_id}", response_model=ProductRead) @router.get("/{product_id}", response_model=ProductRead)
def get_product(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_product(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id))
if product is None: if product is None:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
@@ -59,7 +59,7 @@ def get_product(product_id: int, session: AuthSession = Depends(require_client_s
@router.patch("/{product_id}", response_model=ProductRead) @router.patch("/{product_id}", response_model=ProductRead)
def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_module_access("products", "edit")), db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_id))
if product is None: if product is None:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
@@ -73,7 +73,7 @@ def update_product(product_id: int, payload: ProductUpdate, session: AuthSession
@router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown) @router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown)
def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None: if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
try: try:
@@ -83,7 +83,7 @@ def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(r
@router.get("/{product_id}/price-output", response_model=ProductCostBreakdown) @router.get("/{product_id}/price-output", response_model=ProductCostBreakdown)
def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)):
if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None: if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
try: try:
+7 -7
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload 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.db.session import get_db
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.schemas.raw_material import ( from app.schemas.raw_material import (
@@ -34,7 +34,7 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
@router.get("", response_model=list[RawMaterialRead]) @router.get("", response_model=list[RawMaterialRead])
def list_raw_materials(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def list_raw_materials(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
materials = db.scalars( materials = db.scalars(
select(RawMaterial) select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id) .where(RawMaterial.tenant_id == session.tenant_id)
@@ -45,7 +45,7 @@ def list_raw_materials(session: AuthSession = Depends(require_client_session), d
@router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED)
def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")), db: Session = Depends(get_db)):
material = RawMaterial( material = RawMaterial(
tenant_id=session.tenant_id, tenant_id=session.tenant_id,
name=payload.name, name=payload.name,
@@ -72,7 +72,7 @@ def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depen
@router.get("/{raw_material_id}", response_model=RawMaterialRead) @router.get("/{raw_material_id}", response_model=RawMaterialRead)
def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)):
material = db.scalar( material = db.scalar(
select(RawMaterial) select(RawMaterial)
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id) .where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
@@ -87,7 +87,7 @@ def get_raw_material(raw_material_id: int, session: AuthSession = Depends(requir
def update_raw_material( def update_raw_material(
raw_material_id: int, raw_material_id: int,
payload: RawMaterialUpdate, payload: RawMaterialUpdate,
session: AuthSession = Depends(require_client_session), session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
material = db.scalar( material = db.scalar(
@@ -108,7 +108,7 @@ def update_raw_material(
def add_price_version( def add_price_version(
raw_material_id: int, raw_material_id: int,
payload: RawMaterialPriceVersionCreate, payload: RawMaterialPriceVersionCreate,
session: AuthSession = Depends(require_client_session), session: AuthSession = Depends(require_client_module_access("raw_materials", "edit")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)) material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
@@ -130,7 +130,7 @@ def add_price_version(
@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead]) @router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead])
def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_price_history(raw_material_id: int, 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)) material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
if material is None: if material is None:
raise HTTPException(status_code=404, detail="Raw material not found") raise HTTPException(status_code=404, detail="Raw material not found")
+8 -8
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
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.db.session import get_db
from app.models.scenario import CostingResult, Scenario from app.models.scenario import CostingResult, Scenario
from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse
@@ -12,12 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@router.get("", response_model=list[ScenarioRead]) @router.get("", response_model=list[ScenarioRead])
def list_scenarios(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): 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() return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)
def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)):
scenario = Scenario(tenant_id=session.tenant_id, name=payload.name, description=payload.description, overrides=payload.overrides) scenario = Scenario(tenant_id=session.tenant_id, name=payload.name, description=payload.description, overrides=payload.overrides)
db.add(scenario) db.add(scenario)
db.commit() db.commit()
@@ -26,7 +26,7 @@ def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(requ
@router.get("/{scenario_id}", response_model=ScenarioRead) @router.get("/{scenario_id}", response_model=ScenarioRead)
def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
@@ -34,7 +34,7 @@ def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client
@router.post("/{scenario_id}/run", response_model=ScenarioRunResponse) @router.post("/{scenario_id}/run", response_model=ScenarioRunResponse)
def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
@@ -44,7 +44,7 @@ def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(requi
@router.get("/{scenario_id}/results") @router.get("/{scenario_id}/results")
def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
@@ -65,7 +65,7 @@ def get_scenario_results(scenario_id: int, session: AuthSession = Depends(requir
@router.post("/{scenario_id}/approve", response_model=ScenarioRead) @router.post("/{scenario_id}/approve", response_model=ScenarioRead)
def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
@@ -76,7 +76,7 @@ def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_cl
@router.post("/{scenario_id}/reject", response_model=ScenarioRead) @router.post("/{scenario_id}/reject", response_model=ScenarioRead)
def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)): def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_module_access("scenarios", "edit")), db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id)) scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
if scenario is None: if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found") raise HTTPException(status_code=404, detail="Scenario not found")
+30
View File
@@ -9,6 +9,8 @@ from sqlalchemy.engine import Engine
TENANT_TABLES = { TENANT_TABLES = {
"client_users": None, "client_users": None,
"client_feature_access": None, "client_feature_access": None,
"client_user_module_permissions": None,
"client_access_audit_events": None,
"raw_materials": None, "raw_materials": None,
"raw_material_price_versions": None, "raw_material_price_versions": None,
"mixes": None, "mixes": None,
@@ -123,6 +125,34 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
""" """
), ),
), ),
(
"client_user_module_permissions",
text(
"""
UPDATE client_user_module_permissions
SET tenant_id = (
SELECT client_users.tenant_id
FROM client_users
WHERE client_users.id = client_user_module_permissions.client_user_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
),
(
"client_access_audit_events",
text(
"""
UPDATE client_access_audit_events
SET tenant_id = (
SELECT client_accounts.tenant_id
FROM client_accounts
WHERE client_accounts.id = client_access_audit_events.client_account_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
),
( (
"raw_materials", "raw_materials",
text( text(
+3 -1
View File
@@ -1,5 +1,5 @@
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
@@ -7,8 +7,10 @@ from app.models.scenario import CostingResult, Scenario
__all__ = [ __all__ = [
"ClientAccount", "ClientAccount",
"ClientAccessAuditEvent",
"ClientFeatureAccess", "ClientFeatureAccess",
"ClientUser", "ClientUser",
"ClientUserModulePermission",
"CostingResult", "CostingResult",
"FreightCostRule", "FreightCostRule",
"Mix", "Mix",
+46
View File
@@ -30,6 +30,11 @@ class ClientAccount(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="ClientFeatureAccess.feature_group, ClientFeatureAccess.feature_name", order_by="ClientFeatureAccess.feature_group, ClientFeatureAccess.feature_name",
) )
audit_events: Mapped[list["ClientAccessAuditEvent"]] = relationship(
back_populates="client_account",
cascade="all, delete-orphan",
order_by="ClientAccessAuditEvent.created_at.desc()",
)
class ClientUser(Base): class ClientUser(Base):
@@ -48,6 +53,11 @@ class ClientUser(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_account: Mapped[ClientAccount] = relationship(back_populates="users") client_account: Mapped[ClientAccount] = relationship(back_populates="users")
module_permissions: Mapped[list["ClientUserModulePermission"]] = relationship(
back_populates="client_user",
cascade="all, delete-orphan",
order_by="ClientUserModulePermission.module_key",
)
class ClientFeatureAccess(Base): class ClientFeatureAccess(Base):
@@ -66,3 +76,39 @@ class ClientFeatureAccess(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_account: Mapped[ClientAccount] = relationship(back_populates="features") client_account: Mapped[ClientAccount] = relationship(back_populates="features")
class ClientUserModulePermission(Base):
__tablename__ = "client_user_module_permissions"
__table_args__ = (UniqueConstraint("client_user_id", "module_key", name="uq_client_user_module_permission"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
client_user_id: Mapped[int] = mapped_column(ForeignKey("client_users.id"), index=True)
module_key: Mapped[str] = mapped_column(String(64))
access_level: Mapped[str] = mapped_column(String(32), default="none")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_user: Mapped[ClientUser] = relationship(back_populates="module_permissions")
class ClientAccessAuditEvent(Base):
__tablename__ = "client_access_audit_events"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
actor_type: Mapped[str] = mapped_column(String(32), default="client")
actor_name: Mapped[str] = mapped_column(String(255))
actor_email: Mapped[str] = mapped_column(String(255))
actor_role: Mapped[str] = mapped_column(String(64))
action: Mapped[str] = mapped_column(String(128))
target_type: Mapped[str] = mapped_column(String(64))
target_id: Mapped[int | None] = mapped_column(nullable=True)
module_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
summary: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
client_account: Mapped[ClientAccount] = relationship(back_populates="audit_events")
+37
View File
@@ -24,6 +24,10 @@ class ClientFeatureUpdate(BaseModel):
enabled: bool enabled: bool
class ClientUserModulePermissionUpdate(BaseModel):
access_level: str
class ClientUserRead(BaseModel): class ClientUserRead(BaseModel):
id: int id: int
client_account_id: int client_account_id: int
@@ -34,6 +38,7 @@ class ClientUserRead(BaseModel):
is_new_user: bool is_new_user: bool
last_login_at: datetime | None last_login_at: datetime | None
created_at: datetime created_at: datetime
module_permissions: list["ClientUserModulePermissionRead"]
class ClientFeatureRead(BaseModel): class ClientFeatureRead(BaseModel):
@@ -48,6 +53,34 @@ class ClientFeatureRead(BaseModel):
created_at: datetime created_at: datetime
class ClientUserModulePermissionRead(BaseModel):
id: int
client_account_id: int
client_user_id: int
module_key: str
module_name: str
module_group: str
description: str | None
access_level: str
updated_at: datetime
created_at: datetime
class ClientAccessAuditEventRead(BaseModel):
id: int
client_account_id: int
actor_type: str
actor_name: str
actor_email: str
actor_role: str
action: str
target_type: str
target_id: int | None
module_key: str | None
summary: str
created_at: datetime
class ClientAccessRead(BaseModel): class ClientAccessRead(BaseModel):
id: int id: int
tenant_id: str tenant_id: str
@@ -63,3 +96,7 @@ class ClientAccessRead(BaseModel):
new_user_count: int new_user_count: int
enabled_feature_count: int enabled_feature_count: int
total_feature_count: int total_feature_count: int
audit_history: list[ClientAccessAuditEventRead]
ClientUserRead.model_rebuild()
+31 -14
View File
@@ -4,20 +4,11 @@ from sqlalchemy import select
from app.db.session import Base, SessionLocal, engine from app.db.session import Base, SessionLocal, engine
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
CLIENT_FEATURES = [
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"),
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
]
def seed_client_access(db): def seed_client_access(db):
@@ -51,7 +42,7 @@ def seed_client_access(db):
tenant_id=specialty.tenant_id, tenant_id=specialty.tenant_id,
full_name="Amelia Hart", full_name="Amelia Hart",
email="operator@example.com", email="operator@example.com",
role="admin", role="superadmin",
status="active", status="active",
is_new_user=False, is_new_user=False,
last_login_at=datetime(2026, 4, 24, 11, 30), last_login_at=datetime(2026, 4, 24, 11, 30),
@@ -81,13 +72,13 @@ def seed_client_access(db):
) )
enabled_feature_map = { enabled_feature_map = {
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export"}, "hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export", "client_access"},
"loft-grains": {"dashboard", "products", "powerbi_export"}, "loft-grains": {"dashboard", "products", "powerbi_export"},
} }
for client in (specialty, loft): for client in (specialty, loft):
enabled_keys = enabled_feature_map[client.tenant_id] enabled_keys = enabled_feature_map[client.tenant_id]
for feature_key, feature_name, feature_group, description in CLIENT_FEATURES: for feature_key, feature_name, feature_group, description in MODULE_CATALOG:
client.features.append( client.features.append(
ClientFeatureAccess( ClientFeatureAccess(
tenant_id=client.tenant_id, tenant_id=client.tenant_id,
@@ -99,6 +90,32 @@ def seed_client_access(db):
) )
) )
for user in client.users:
for module_key, _, _, _ in MODULE_CATALOG:
user.module_permissions.append(
ClientUserModulePermission(
tenant_id=client.tenant_id,
client_account_id=client.id,
module_key=module_key,
access_level=default_access_level_for_role(user.role, module_key),
)
)
specialty.audit_events.append(
ClientAccessAuditEvent(
tenant_id=specialty.tenant_id,
actor_type="seed",
actor_name="Lean 101 Seeder",
actor_email="system@lean101.local",
actor_role="system",
action="client_access.seeded",
target_type="client_account",
target_id=specialty.id,
module_key="client_access",
summary="Initial client access controls, module permissions, and feature flags were seeded.",
)
)
def seed_costing_workspace(db): def seed_costing_workspace(db):
existing = db.scalar(select(RawMaterial.id)) existing = db.scalar(select(RawMaterial.id))
+191 -2
View File
@@ -5,21 +5,107 @@ from datetime import datetime
from sqlalchemy import Select, select from sqlalchemy import Select, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser from app.models.client_access import (
ClientAccessAuditEvent,
ClientAccount,
ClientFeatureAccess,
ClientUser,
ClientUserModulePermission,
)
MODULE_CATALOG = (
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"),
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
)
MODULE_INDEX = {
key: {"module_name": name, "module_group": group, "description": description}
for key, name, group, description in MODULE_CATALOG
}
ACCESS_LEVEL_ORDER = {"none": 0, "view": 1, "edit": 2, "manage": 3}
def client_access_query() -> Select[tuple[ClientAccount]]: def client_access_query() -> Select[tuple[ClientAccount]]:
return ( return (
select(ClientAccount) select(ClientAccount)
.options(selectinload(ClientAccount.users), selectinload(ClientAccount.features)) .options(
selectinload(ClientAccount.users).selectinload(ClientUser.module_permissions),
selectinload(ClientAccount.features),
selectinload(ClientAccount.audit_events),
)
.order_by(ClientAccount.name) .order_by(ClientAccount.name)
) )
def list_client_accounts(db: Session) -> list[ClientAccount]: def list_client_accounts(db: Session) -> list[ClientAccount]:
ensure_client_user_module_permissions(db)
return db.scalars(client_access_query()).all() return db.scalars(client_access_query()).all()
def get_client_user_by_email(db: Session, *, email: str, tenant_id: str | None = None) -> ClientUser | None:
statement = select(ClientUser).where(ClientUser.email == email)
if tenant_id:
statement = statement.where(ClientUser.tenant_id == tenant_id)
return db.scalar(
statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc())
)
def module_access_map(user: ClientUser) -> dict[str, str]:
return {permission.module_key: permission.access_level for permission in user.module_permissions}
def has_access_level(access_level: str | None, minimum_level: str) -> bool:
return ACCESS_LEVEL_ORDER.get(access_level or "none", 0) >= ACCESS_LEVEL_ORDER.get(minimum_level, 0)
def default_access_level_for_role(role: str, module_key: str) -> str:
normalized = role.strip().lower()
if normalized == "superadmin":
return "manage" if module_key == "client_access" else "edit"
if normalized == "admin":
return "edit" if module_key != "client_access" else "none"
if normalized == "operator":
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "products", "scenarios"} else "none"
if normalized == "viewer":
return "view" if module_key in {"dashboard", "products", "powerbi_export"} else "none"
return "none"
def ensure_user_module_permissions(db: Session, user: ClientUser) -> bool:
existing = {permission.module_key for permission in user.module_permissions}
created = False
for module_key, _, _, _ in MODULE_CATALOG:
if module_key in existing:
continue
db.add(
ClientUserModulePermission(
tenant_id=user.tenant_id,
client_account_id=user.client_account_id,
client_user_id=user.id,
module_key=module_key,
access_level=default_access_level_for_role(user.role, module_key),
)
)
created = True
return created
def ensure_client_user_module_permissions(db: Session) -> None:
users = db.scalars(select(ClientUser).options(selectinload(ClientUser.module_permissions))).all()
changed = False
for user in users:
changed = ensure_user_module_permissions(db, user) or changed
if changed:
db.commit()
def serialize_client_user(user: ClientUser) -> dict: def serialize_client_user(user: ClientUser) -> dict:
return { return {
"id": user.id, "id": user.id,
@@ -31,6 +117,7 @@ def serialize_client_user(user: ClientUser) -> dict:
"is_new_user": user.is_new_user, "is_new_user": user.is_new_user,
"last_login_at": user.last_login_at, "last_login_at": user.last_login_at,
"created_at": user.created_at, "created_at": user.created_at,
"module_permissions": [serialize_module_permission(permission) for permission in user.module_permissions],
} }
@@ -48,6 +135,39 @@ def serialize_client_feature(feature: ClientFeatureAccess) -> dict:
} }
def serialize_module_permission(permission: ClientUserModulePermission) -> dict:
module_info = MODULE_INDEX.get(permission.module_key, {})
return {
"id": permission.id,
"client_account_id": permission.client_account_id,
"client_user_id": permission.client_user_id,
"module_key": permission.module_key,
"module_name": module_info.get("module_name", permission.module_key.replace("_", " ").title()),
"module_group": module_info.get("module_group", "workspace"),
"description": module_info.get("description"),
"access_level": permission.access_level,
"updated_at": permission.updated_at,
"created_at": permission.created_at,
}
def serialize_audit_event(event: ClientAccessAuditEvent) -> dict:
return {
"id": event.id,
"client_account_id": event.client_account_id,
"actor_type": event.actor_type,
"actor_name": event.actor_name,
"actor_email": event.actor_email,
"actor_role": event.actor_role,
"action": event.action,
"target_type": event.target_type,
"target_id": event.target_id,
"module_key": event.module_key,
"summary": event.summary,
"created_at": event.created_at,
}
def serialize_client_account(client: ClientAccount) -> dict: def serialize_client_account(client: ClientAccount) -> dict:
users = [serialize_client_user(user) for user in client.users] users = [serialize_client_user(user) for user in client.users]
features = [serialize_client_feature(feature) for feature in client.features] features = [serialize_client_feature(feature) for feature in client.features]
@@ -70,6 +190,7 @@ def serialize_client_account(client: ClientAccount) -> dict:
"new_user_count": new_users, "new_user_count": new_users,
"enabled_feature_count": enabled_features, "enabled_feature_count": enabled_features,
"total_feature_count": len(features), "total_feature_count": len(features),
"audit_history": [serialize_audit_event(event) for event in client.audit_events[:40]],
} }
@@ -78,6 +199,8 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict:
client_rows = [] client_rows = []
user_rows = [] user_rows = []
feature_rows = [] feature_rows = []
permission_rows = []
audit_rows = []
for client in serialized_clients: for client in serialized_clients:
client_rows.append( client_rows.append(
@@ -111,6 +234,21 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict:
} }
) )
for permission in user["module_permissions"]:
permission_rows.append(
{
"client_id": client["id"],
"client_name": client["name"],
"user_id": user["id"],
"user_email": user["email"],
"module_key": permission["module_key"],
"module_name": permission["module_name"],
"module_group": permission["module_group"],
"access_level": permission["access_level"],
"updated_at": permission["updated_at"],
}
)
for feature in client["features"]: for feature in client["features"]:
feature_rows.append( feature_rows.append(
{ {
@@ -125,10 +263,61 @@ def build_client_access_export(clients: list[ClientAccount]) -> dict:
} }
) )
for event in client["audit_history"]:
audit_rows.append(
{
"client_id": client["id"],
"client_name": client["name"],
"event_id": event["id"],
"actor_email": event["actor_email"],
"actor_role": event["actor_role"],
"action": event["action"],
"target_type": event["target_type"],
"target_id": event["target_id"],
"module_key": event["module_key"],
"summary": event["summary"],
"created_at": event["created_at"],
}
)
return { return {
"generated_at": datetime.utcnow(), "generated_at": datetime.utcnow(),
"client_rows": client_rows, "client_rows": client_rows,
"user_rows": user_rows, "user_rows": user_rows,
"feature_rows": feature_rows, "feature_rows": feature_rows,
"permission_rows": permission_rows,
"audit_rows": audit_rows,
"clients": serialized_clients, "clients": serialized_clients,
} }
def record_audit_event(
db: Session,
*,
tenant_id: str,
client_account_id: int,
actor_type: str,
actor_name: str,
actor_email: str,
actor_role: str,
action: str,
target_type: str,
target_id: int | None,
module_key: str | None,
summary: str,
) -> None:
db.add(
ClientAccessAuditEvent(
tenant_id=tenant_id,
client_account_id=client_account_id,
actor_type=actor_type,
actor_name=actor_name,
actor_email=actor_email,
actor_role=actor_role,
action=action,
target_type=target_type,
target_id=target_id,
module_key=module_key,
summary=summary,
)
)
+66 -3
View File
@@ -10,11 +10,11 @@ from app.db.migrations import bootstrap_schema, sync_tenant_ids
from app.db.session import Base from app.db.session import Base
from app.main import app from app.main import app
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.services.client_access_service import build_client_access_export, serialize_client_account 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 from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost
@@ -101,6 +101,8 @@ def test_root_and_login_endpoints():
assert client_login_response.status_code == 200 assert client_login_response.status_code == 200
assert client_login_response.json()["email"] == settings.client_email assert client_login_response.json()["email"] == settings.client_email
assert client_login_response.json()["tenant_id"] == settings.client_tenant_id assert client_login_response.json()["tenant_id"] == settings.client_tenant_id
assert client_login_response.json()["client_role"] == "superadmin"
assert client_login_response.json()["module_permissions"]["client_access"] == "manage"
admin_login_response = client.post( admin_login_response = client.post(
"/api/auth/admin/login", "/api/auth/admin/login",
@@ -125,7 +127,7 @@ def test_client_access_export_helpers():
ClientUser( ClientUser(
full_name="Amelia Hart", full_name="Amelia Hart",
email="amelia.hart@specialtyfeeds.example", email="amelia.hart@specialtyfeeds.example",
role="admin", role="superadmin",
status="active", status="active",
is_new_user=False, is_new_user=False,
), ),
@@ -155,6 +157,23 @@ def test_client_access_export_helpers():
] ]
) )
db.add(client) db.add(client)
db.flush()
for user in client.users:
ensure_user_module_permissions(db, user)
client.audit_events.append(
ClientAccessAuditEvent(
tenant_id=client.tenant_id,
actor_type="lean_admin",
actor_name="Lean 101",
actor_email="admin@lean101.local",
actor_role="admin",
action="client_access.seeded",
target_type="client_account",
target_id=client.id,
module_key="client_access",
summary="Initial client access controls were seeded.",
)
)
db.commit() db.commit()
db.refresh(client) db.refresh(client)
@@ -167,6 +186,8 @@ def test_client_access_export_helpers():
assert export["client_rows"][0]["client_code"] == "SPEC" assert export["client_rows"][0]["client_code"] == "SPEC"
assert export["user_rows"][0]["client_name"] == "Specialty Feeds" assert export["user_rows"][0]["client_name"] == "Specialty Feeds"
assert len(export["feature_rows"]) == 2 assert len(export["feature_rows"]) == 2
assert len(export["permission_rows"]) >= 1
assert len(export["audit_rows"]) == 1
def test_client_access_endpoints(): def test_client_access_endpoints():
@@ -181,10 +202,52 @@ def test_client_access_endpoints():
access_response = client.get("/api/client-access", headers=headers) access_response = client.get("/api/client-access", headers=headers)
assert access_response.status_code == 200 assert access_response.status_code == 200
assert len(access_response.json()) >= 1 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", headers=headers)
assert export_response.status_code == 200 assert export_response.status_code == 200
assert "client_rows" in export_response.json() assert "client_rows" in export_response.json()
assert "permission_rows" in export_response.json()
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']}"}
superadmin_access_response = client.get("/api/client-access", headers=client_headers)
assert superadmin_access_response.status_code == 200
assert len(superadmin_access_response.json()) == 1
def test_module_permission_blocks_client_module_access():
with TestClient(app) as client:
admin_login_response = client.post(
"/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)
first_client = access_response.json()[0]
first_user = first_client["users"][0]
permission = next(
permission for permission in first_user["module_permissions"] if permission["module_key"] == "raw_materials"
)
client.patch(
f"/api/client-access/users/{first_user['id']}/module-permissions/{permission['module_key']}",
json={"access_level": "none"},
headers=admin_headers,
)
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)
assert raw_materials_response.status_code == 403
def test_bootstrap_schema_creates_missing_tables_and_patches_legacy_tenant_columns(): def test_bootstrap_schema_creates_missing_tables_and_patches_legacy_tenant_columns():
+18 -6
View File
@@ -13,6 +13,7 @@ import type {
ClientAccessAccount, ClientAccessAccount,
ClientAccessPowerBiExport, ClientAccessPowerBiExport,
ClientUserCreateInput, ClientUserCreateInput,
ClientUserModulePermission,
ClientUserUpdateInput, ClientUserUpdateInput,
LoginResponse, LoginResponse,
Mix, Mix,
@@ -30,7 +31,7 @@ import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000'; const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
type AuthMode = 'none' | 'client' | 'admin'; type AuthMode = 'none' | 'client' | 'admin' | 'manager';
type ApiFetch = typeof fetch; type ApiFetch = typeof fetch;
function getApiBaseUrl() { function getApiBaseUrl() {
@@ -63,6 +64,10 @@ function getToken(auth: AuthMode) {
return getStoredAdminSession()?.token ?? null; return getStoredAdminSession()?.token ?? null;
} }
if (auth === 'manager') {
return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null;
}
return null; return null;
} }
@@ -127,9 +132,9 @@ export const api = {
productCosts: (fetcher?: ApiFetch) => productCosts: (fetcher?: ApiFetch) =>
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher), fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher), scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'admin', fetcher), clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
clientAccessExport: (fetcher?: ApiFetch) => clientAccessExport: (fetcher?: ApiFetch) =>
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'admin', fetcher), fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher), dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
clientLogin: (email: string, password: string) => clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', { request<LoginResponse>('/api/auth/client/login', {
@@ -141,6 +146,8 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })
}), }),
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),
login: (email: string, password: string) => login: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', { request<LoginResponse>('/api/auth/client/login', {
method: 'POST', method: 'POST',
@@ -184,15 +191,20 @@ export const api = {
request<ClientAccessAccount>('/api/client-access/users', { request<ClientAccessAccount>('/api/client-access/users', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'admin'), }, 'manager'),
updateClientUser: (userId: number, payload: ClientUserUpdateInput) => updateClientUser: (userId: number, payload: ClientUserUpdateInput) =>
request<ClientAccessAccount>(`/api/client-access/users/${userId}`, { request<ClientAccessAccount>(`/api/client-access/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'admin'), }, 'manager'),
updateClientUserModulePermission: (userId: number, permission: Pick<ClientUserModulePermission, 'module_key'>, payload: { access_level: string }) =>
request<ClientAccessAccount>(`/api/client-access/users/${userId}/module-permissions/${permission.module_key}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'manager'),
updateClientFeature: (featureId: number, payload: { enabled: boolean }) => updateClientFeature: (featureId: number, payload: { enabled: boolean }) =>
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, { request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'admin') }, 'manager')
}; };
File diff suppressed because it is too large Load Diff
+163 -87
View File
@@ -2,7 +2,7 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { clientSession, sessionHydrated } from '$lib/session'; import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import packageInfo from '../../../package.json'; import packageInfo from '../../../package.json';
@@ -18,22 +18,23 @@
label: string; label: string;
shortLabel: string; shortLabel: string;
icon?: 'home'; icon?: 'home';
moduleKey?: string;
}; };
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home' }; const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' };
const workingDocumentItems: NavItem[] = [ const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM' }, { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM' }, { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
{ href: '/products', label: 'Products', shortLabel: 'PR' }, { href: '/products', label: 'Products', shortLabel: 'PR', moduleKey: 'products' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC' } { href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' }
]; ];
const navigation = [dashboardItem, ...workingDocumentItems]; const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', moduleKey: 'client_access' };
const navigation = [dashboardItem, ...workingDocumentItems, accessControlItem];
const footerLinks = [ const footerLinks = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' }, { href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' } { href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
]; ];
const primaryBottomNavigation = [dashboardItem, ...workingDocumentItems.slice(0, 3)];
const searchItems: SearchItem[] = [ const searchItems: SearchItem[] = [
{ {
@@ -88,12 +89,30 @@
let quickMenuOpen = $state(false); let quickMenuOpen = $state(false);
let userMenuOpen = $state(false); let userMenuOpen = $state(false);
let navOpen = $state(false); let navOpen = $state(false);
let workingDocumentsExpanded = $state(true);
let showBottomNav = $state(false); let showBottomNav = $state(false);
let isRestoringSession = $state(false); let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null); let restoredToken = $state<string | null>(null);
let paletteInput: HTMLInputElement | null = $state(null); let paletteInput: HTMLInputElement | null = $state(null);
const appVersion = `v${packageInfo.version}`; const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const visibleDashboardItem = $derived(
!$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null
);
const visibleWorkingDocumentItems = $derived(
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey))
);
const visibleFooterLinks = $derived([
...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
? []
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }])
]);
const primaryBottomNavigation = $derived(
[...(visibleDashboardItem ? [visibleDashboardItem] : []), ...visibleWorkingDocumentItems.slice(0, 3)]
);
function matchesRoute(href: string, pathname: string) { function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href); return href === '/' ? pathname === '/' : pathname.startsWith(href);
@@ -111,7 +130,8 @@
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce', '/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
'/products': 'Track delivered product pricing and margin views', '/products': 'Track delivered product pricing and margin views',
'/settings': 'Review your workspace profile and application settings', '/settings': 'Review your workspace profile and application settings',
'/scenarios': 'Compare alternate pricing and production assumptions' '/scenarios': 'Compare alternate pricing and production assumptions',
'/client-access': 'Manage user access, module permissions, and audit history'
}; };
return descriptions[pathname] ?? 'Hunter Premium Produce client workspace'; return descriptions[pathname] ?? 'Hunter Premium Produce client workspace';
@@ -256,50 +276,63 @@
</button> </button>
<nav class="nav-list" aria-label="Client navigation"> <nav class="nav-list" aria-label="Client navigation">
<a class:active={matchesRoute(dashboardItem.href, page.url.pathname)} href={dashboardItem.href}> {#if visibleDashboardItem}
<span class="nav-icon"> <a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"> <span class="nav-icon">
<path <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
d="M3.75 10.5 12 3.75l8.25 6.75" <path
stroke="currentColor" d="M3.75 10.5 12 3.75l8.25 6.75"
stroke-width="1.85" stroke="currentColor"
stroke-linecap="round" stroke-width="1.85"
stroke-linejoin="round" stroke-linecap="round"
/> stroke-linejoin="round"
<path />
d="M5.25 9.75v9h13.5v-9" <path
stroke="currentColor" d="M5.25 9.75v9h13.5v-9"
stroke-width="1.85" stroke="currentColor"
stroke-linecap="round" stroke-width="1.85"
stroke-linejoin="round" stroke-linecap="round"
/> stroke-linejoin="round"
<path />
d="M10.125 18.75v-5.25h3.75v5.25" <path
stroke="currentColor" d="M10.125 18.75v-5.25h3.75v5.25"
stroke-width="1.85" stroke="currentColor"
stroke-linecap="round" stroke-width="1.85"
stroke-linejoin="round" stroke-linecap="round"
/> stroke-linejoin="round"
</svg> />
</span> </svg>
<span>{dashboardItem.label}</span> </span>
</a> <span>{visibleDashboardItem.label}</span>
</a>
{/if}
</nav> </nav>
<div class="nav-group" aria-label="Working documents"> <div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}>
<p class="nav-group-label">Working Documents</p> <button
<nav class="nav-sublist" aria-label="Working document pages"> aria-controls="working-documents-nav"
{#each workingDocumentItems as item} aria-expanded={workingDocumentsExpanded}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}> class="nav-group-toggle"
<span class="nav-icon">{item.shortLabel}</span> type="button"
<span>{item.label}</span> onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
</a> >
{/each} <span class="nav-group-label">Working Documents</span>
</nav> <span class:open={workingDocumentsExpanded} class="chevron"></span>
</button>
{#if workingDocumentsExpanded}
<nav class="nav-sublist" id="working-documents-nav" aria-label="Working document pages">
{#each visibleWorkingDocumentItems as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
{/if}
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
{#each footerLinks as item} {#each visibleFooterLinks as item}
<a href={item.href}> <a href={item.href}>
<span class="nav-icon muted">{item.shortLabel}</span> <span class="nav-icon muted">{item.shortLabel}</span>
<span>{item.label}</span> <span>{item.label}</span>
@@ -498,43 +531,58 @@
<div class="drawer-grid"> <div class="drawer-grid">
<nav class="drawer-section" aria-label="All workspace pages"> <nav class="drawer-section" aria-label="All workspace pages">
<a class:active={matchesRoute(dashboardItem.href, page.url.pathname)} href={dashboardItem.href} onclick={() => (navOpen = false)}> {#if visibleDashboardItem}
<span class="nav-icon"> <a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href} onclick={() => (navOpen = false)}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"> <span class="nav-icon">
<path <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
d="M3.75 10.5 12 3.75l8.25 6.75" <path
stroke="currentColor" d="M3.75 10.5 12 3.75l8.25 6.75"
stroke-width="1.85" stroke="currentColor"
stroke-linecap="round" stroke-width="1.85"
stroke-linejoin="round" stroke-linecap="round"
/> stroke-linejoin="round"
<path />
d="M5.25 9.75v9h13.5v-9" <path
stroke="currentColor" d="M5.25 9.75v9h13.5v-9"
stroke-width="1.85" stroke="currentColor"
stroke-linecap="round" stroke-width="1.85"
stroke-linejoin="round" stroke-linecap="round"
/> stroke-linejoin="round"
<path />
d="M10.125 18.75v-5.25h3.75v5.25" <path
stroke="currentColor" d="M10.125 18.75v-5.25h3.75v5.25"
stroke-width="1.85" stroke="currentColor"
stroke-linecap="round" stroke-width="1.85"
stroke-linejoin="round" stroke-linecap="round"
/> stroke-linejoin="round"
</svg> />
</span> </svg>
<span>{dashboardItem.label}</span> </span>
</a> <span>{visibleDashboardItem.label}</span>
</a>
{/if}
<div class="drawer-group"> <div class="drawer-group" hidden={!visibleWorkingDocumentItems.length}>
<p class="drawer-group-label">Working Documents</p> <button
{#each workingDocumentItems as item} aria-controls="drawer-working-documents-nav"
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}> aria-expanded={workingDocumentsExpanded}
<span class="nav-icon">{item.shortLabel}</span> class="nav-group-toggle drawer-group-toggle"
<span>{item.label}</span> type="button"
</a> onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
{/each} >
<span class="drawer-group-label">Working Documents</span>
<span class:open={workingDocumentsExpanded} class="chevron"></span>
</button>
{#if workingDocumentsExpanded}
<div id="drawer-working-documents-nav" class="drawer-sublist">
{#each visibleWorkingDocumentItems as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
{/if}
</div> </div>
</nav> </nav>
@@ -565,7 +613,7 @@
</div> </div>
<div class="drawer-footer"> <div class="drawer-footer">
{#each footerLinks as item} {#each visibleFooterLinks as item}
<a href={item.href} onclick={() => (navOpen = false)}> <a href={item.href} onclick={() => (navOpen = false)}>
<span>{item.label}</span> <span>{item.label}</span>
<small>{item.shortLabel}</small> <small>{item.shortLabel}</small>
@@ -683,6 +731,10 @@
padding: 0.9rem; padding: 0.9rem;
background: var(--panel); background: var(--panel);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
} }
.sidebar-body { .sidebar-body {
@@ -865,6 +917,19 @@
padding-top: 0.15rem; padding-top: 0.15rem;
} }
.nav-group-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
padding: 0 0.68rem;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.nav-group-label, .nav-group-label,
.drawer-group-label { .drawer-group-label {
margin: 0; margin: 0;
@@ -876,7 +941,7 @@
} }
.nav-group-label { .nav-group-label {
padding: 0 0.68rem; padding: 0;
} }
.nav-sublist { .nav-sublist {
@@ -917,6 +982,7 @@
.sidebar-footer { .sidebar-footer {
margin-top: auto; margin-top: auto;
padding-top: 0.6rem; padding-top: 0.6rem;
flex-shrink: 0;
} }
.sidebar-meta { .sidebar-meta {
@@ -925,6 +991,7 @@
padding: 0.85rem 0.3rem 0; padding: 0.85rem 0.3rem 0;
color: var(--muted); color: var(--muted);
font-size: 0.78rem; font-size: 0.78rem;
flex-shrink: 0;
} }
.sidebar-meta small { .sidebar-meta small {
@@ -1429,10 +1496,19 @@
padding-top: 0.35rem; padding-top: 0.35rem;
} }
.drawer-group-label { .drawer-group-toggle {
padding: 0 0.2rem; padding: 0 0.2rem;
} }
.drawer-group-label {
padding: 0;
}
.drawer-sublist {
display: grid;
gap: 0.4rem;
}
.drawer-footer { .drawer-footer {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
+153 -8
View File
@@ -8,6 +8,46 @@ import type {
Scenario Scenario
} from '$lib/types'; } from '$lib/types';
const MODULE_PERMISSIONS = {
superadmin: {
dashboard: 'edit',
raw_materials: 'edit',
mix_master: 'edit',
products: 'edit',
scenarios: 'edit',
powerbi_export: 'edit',
client_access: 'manage'
},
operator: {
dashboard: 'edit',
raw_materials: 'edit',
mix_master: 'edit',
products: 'edit',
scenarios: 'edit',
powerbi_export: 'none',
client_access: 'none'
},
viewer: {
dashboard: 'view',
raw_materials: 'none',
mix_master: 'none',
products: 'view',
scenarios: 'none',
powerbi_export: 'view',
client_access: 'none'
}
} as const;
const MODULE_DETAILS = [
['dashboard', 'Dashboard', 'workspace', 'Top-level operational dashboard'],
['raw_materials', 'Raw Materials', 'costing', 'Maintain live material costs and versions'],
['mix_master', 'Mix Master', 'costing', 'Create and maintain mix worksheets'],
['products', 'Products', 'pricing', 'Review finished product pricing'],
['scenarios', 'Scenarios', 'planning', 'Run scenario overrides and comparisons'],
['powerbi_export', 'Power BI Export', 'reporting', 'Expose client access data to BI consumers'],
['client_access', 'Client Access', 'administration', 'Manage user access, module permissions, and audit history']
] as const;
export const mockRawMaterials: RawMaterial[] = [ export const mockRawMaterials: RawMaterial[] = [
{ {
id: 1, id: 1,
@@ -117,19 +157,31 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00', created_at: '2026-04-20T09:00:00',
active_user_count: 1, active_user_count: 1,
new_user_count: 1, new_user_count: 1,
enabled_feature_count: 6, enabled_feature_count: 7,
total_feature_count: 6, total_feature_count: 7,
users: [ users: [
{ {
id: 1, id: 1,
client_account_id: 1, client_account_id: 1,
full_name: 'Amelia Hart', full_name: 'Amelia Hart',
email: 'operator@example.com', email: 'operator@example.com',
role: 'admin', role: 'superadmin',
status: 'active', status: 'active',
is_new_user: false, is_new_user: false,
last_login_at: '2026-04-24T11:30:00', last_login_at: '2026-04-24T11:30:00',
created_at: '2026-04-20T09:00:00' created_at: '2026-04-20T09:00:00',
module_permissions: MODULE_DETAILS.map(([module_key, module_name, module_group, description], index) => ({
id: index + 1,
client_account_id: 1,
client_user_id: 1,
module_key,
module_name,
module_group,
description,
access_level: MODULE_PERMISSIONS.superadmin[module_key],
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
}))
}, },
{ {
id: 2, id: 2,
@@ -140,7 +192,19 @@ export const mockClientAccess: ClientAccessAccount[] = [
status: 'invited', status: 'invited',
is_new_user: true, is_new_user: true,
last_login_at: null, last_login_at: null,
created_at: '2026-04-24T15:00:00' created_at: '2026-04-24T15:00:00',
module_permissions: MODULE_DETAILS.map(([module_key, module_name, module_group, description], index) => ({
id: index + 101,
client_account_id: 1,
client_user_id: 2,
module_key,
module_name,
module_group,
description,
access_level: MODULE_PERMISSIONS.operator[module_key],
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-24T15:00:00'
}))
} }
], ],
features: [ features: [
@@ -209,6 +273,33 @@ export const mockClientAccess: ClientAccessAccount[] = [
enabled: true, enabled: true,
updated_at: '2026-04-24T15:00:00', updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00' created_at: '2026-04-20T09:00:00'
},
{
id: 13,
client_account_id: 1,
feature_key: 'client_access',
feature_name: 'Client Access',
feature_group: 'administration',
description: 'Manage user access, module permissions, and audit history',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
}
],
audit_history: [
{
id: 1,
client_account_id: 1,
actor_type: 'lean_admin',
actor_name: 'Lean 101 Seeder',
actor_email: 'system@lean101.local',
actor_role: 'system',
action: 'client_access.seeded',
target_type: 'client_account',
target_id: 1,
module_key: 'client_access',
summary: 'Initial client access controls, module permissions, and feature flags were seeded.',
created_at: '2026-04-20T09:00:00'
} }
] ]
}, },
@@ -224,7 +315,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
active_user_count: 1, active_user_count: 1,
new_user_count: 0, new_user_count: 0,
enabled_feature_count: 3, enabled_feature_count: 3,
total_feature_count: 6, total_feature_count: 7,
users: [ users: [
{ {
id: 3, id: 3,
@@ -235,7 +326,19 @@ export const mockClientAccess: ClientAccessAccount[] = [
status: 'active', status: 'active',
is_new_user: false, is_new_user: false,
last_login_at: '2026-04-22T09:10:00', last_login_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00' created_at: '2026-04-21T10:00:00',
module_permissions: MODULE_DETAILS.map(([module_key, module_name, module_group, description], index) => ({
id: index + 201,
client_account_id: 2,
client_user_id: 3,
module_key,
module_name,
module_group,
description,
access_level: MODULE_PERMISSIONS.viewer[module_key],
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
}))
} }
], ],
features: [ features: [
@@ -304,8 +407,20 @@ export const mockClientAccess: ClientAccessAccount[] = [
enabled: true, enabled: true,
updated_at: '2026-04-22T09:10:00', updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00' created_at: '2026-04-21T10:00:00'
},
{
id: 14,
client_account_id: 2,
feature_key: 'client_access',
feature_name: 'Client Access',
feature_group: 'administration',
description: 'Manage user access, module permissions, and audit history',
enabled: false,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
} }
] ],
audit_history: []
} }
]; ];
@@ -349,5 +464,35 @@ export const mockClientAccessExport: ClientAccessPowerBiExport = {
updated_at: feature.updated_at updated_at: feature.updated_at
})) }))
), ),
permission_rows: mockClientAccess.flatMap((client) =>
client.users.flatMap((user) =>
user.module_permissions.map((permission) => ({
client_id: client.id,
client_name: client.name,
user_id: user.id,
user_email: user.email,
module_key: permission.module_key,
module_name: permission.module_name,
module_group: permission.module_group,
access_level: permission.access_level,
updated_at: permission.updated_at
}))
)
),
audit_rows: mockClientAccess.flatMap((client) =>
client.audit_history.map((event) => ({
client_id: client.id,
client_name: client.name,
event_id: event.id,
actor_email: event.actor_email,
actor_role: event.actor_role,
action: event.action,
target_type: event.target_type,
target_id: event.target_id,
module_key: event.module_key,
summary: event.summary,
created_at: event.created_at
}))
),
clients: mockClientAccess clients: mockClientAccess
}; };
+28
View File
@@ -7,6 +7,17 @@ export type AppSession = {
role: string; role: string;
token: string; token: string;
tenant_id?: string | null; tenant_id?: string | null;
client_role?: string | null;
user_id?: number | null;
client_account_id?: number | null;
module_permissions?: Record<string, string>;
};
const ACCESS_LEVEL_ORDER: Record<string, number> = {
none: 0,
view: 1,
edit: 2,
manage: 3
}; };
const CLIENT_STORAGE_KEY = 'data-entry-app-client-session'; const CLIENT_STORAGE_KEY = 'data-entry-app-client-session';
@@ -74,6 +85,23 @@ export function hasStoredAdminSession() {
return getStoredAdminSession() !== null; return getStoredAdminSession() !== null;
} }
export function hasModuleAccess(
session: AppSession | null | undefined,
moduleKey: string,
minimumLevel: 'view' | 'edit' | 'manage' = 'view'
) {
if (!session) {
return false;
}
if (session.role === 'admin') {
return true;
}
const currentLevel = session.module_permissions?.[moduleKey] ?? 'none';
return (ACCESS_LEVEL_ORDER[currentLevel] ?? 0) >= ACCESS_LEVEL_ORDER[minimumLevel];
}
export const sessionHydrated = readable(false, (set) => { export const sessionHydrated = readable(false, (set) => {
if (!browser) { if (!browser) {
return undefined; return undefined;
+36
View File
@@ -130,6 +130,20 @@ export type ClientAccessUser = {
is_new_user: boolean; is_new_user: boolean;
last_login_at?: string | null; last_login_at?: string | null;
created_at: string; created_at: string;
module_permissions: ClientUserModulePermission[];
};
export type ClientUserModulePermission = {
id: number;
client_account_id: number;
client_user_id: number;
module_key: string;
module_name: string;
module_group: string;
description?: string | null;
access_level: string;
updated_at: string;
created_at: string;
}; };
export type ClientAccessFeature = { export type ClientAccessFeature = {
@@ -159,6 +173,22 @@ export type ClientAccessAccount = {
new_user_count: number; new_user_count: number;
enabled_feature_count: number; enabled_feature_count: number;
total_feature_count: number; total_feature_count: number;
audit_history: ClientAccessAuditEvent[];
};
export type ClientAccessAuditEvent = {
id: number;
client_account_id: number;
actor_type: string;
actor_name: string;
actor_email: string;
actor_role: string;
action: string;
target_type: string;
target_id?: number | null;
module_key?: string | null;
summary: string;
created_at: string;
}; };
export type ClientAccessExportRow = Record<string, unknown>; export type ClientAccessExportRow = Record<string, unknown>;
@@ -168,6 +198,8 @@ export type ClientAccessPowerBiExport = {
client_rows: ClientAccessExportRow[]; client_rows: ClientAccessExportRow[];
user_rows: ClientAccessExportRow[]; user_rows: ClientAccessExportRow[];
feature_rows: ClientAccessExportRow[]; feature_rows: ClientAccessExportRow[];
permission_rows: ClientAccessExportRow[];
audit_rows: ClientAccessExportRow[];
clients: ClientAccessAccount[]; clients: ClientAccessAccount[];
}; };
@@ -177,6 +209,10 @@ export type LoginResponse = {
role: string; role: string;
token: string; token: string;
tenant_id?: string | null; tenant_id?: string | null;
client_role?: string | null;
user_id?: number | null;
client_account_id?: number | null;
module_permissions?: Record<string, string>;
}; };
export type RawMaterialCreateInput = { export type RawMaterialCreateInput = {
+8 -6
View File
@@ -1,4 +1,4 @@
import { hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load({ fetch }) { export async function load({ fetch }) {
@@ -12,13 +12,15 @@ export async function load({ fetch }) {
}; };
} }
const session = getStoredClientSession();
try { try {
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([ const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
api.rawMaterials(fetch), hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
api.mixes(fetch), hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
api.productCosts(fetch), hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([]),
api.scenarios(fetch), hasModuleAccess(session, 'scenarios') ? api.scenarios(fetch) : Promise.resolve([]),
api.dataQuality(fetch) hasModuleAccess(session, 'dashboard') ? api.dataQuality(fetch) : Promise.resolve([])
]); ]);
return { return {
+4
View File
@@ -10,6 +10,8 @@ export async function load({ fetch }) {
client_rows: [], client_rows: [],
user_rows: [], user_rows: [],
feature_rows: [], feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: [] clients: []
} }
}; };
@@ -30,6 +32,8 @@ export async function load({ fetch }) {
client_rows: [], client_rows: [],
user_rows: [], user_rows: [],
feature_rows: [], feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: [] clients: []
} }
}; };
@@ -10,6 +10,8 @@ export async function load({ fetch }) {
client_rows: [], client_rows: [],
user_rows: [], user_rows: [],
feature_rows: [], feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: [] clients: []
} }
}; };
@@ -30,6 +32,8 @@ export async function load({ fetch }) {
client_rows: [], client_rows: [],
user_rows: [], user_rows: [],
feature_rows: [], feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: [] clients: []
} }
}; };
+28 -3
View File
@@ -1,5 +1,30 @@
import { redirect } from '@sveltejs/kit'; import { api } from '$lib/api';
import { hasStoredAdminSession, hasStoredClientSession } from '$lib/session';
export function load() { function emptyPayload() {
throw redirect(307, '/admin/client-access'); return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
export async function load({ fetch }) {
if (!hasStoredAdminSession() && !hasStoredClientSession()) {
return emptyPayload();
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return { clients, exportPreview };
} catch {
return emptyPayload();
}
} }
+14 -2
View File
@@ -13,8 +13,10 @@ const apiMocks = vi.hoisted(() => ({
})); }));
const sessionMocks = vi.hoisted(() => ({ const sessionMocks = vi.hoisted(() => ({
getStoredClientSession: vi.fn(),
hasStoredClientSession: vi.fn(), hasStoredClientSession: vi.fn(),
hasStoredAdminSession: vi.fn() hasStoredAdminSession: vi.fn(),
hasModuleAccess: vi.fn()
})); }));
vi.mock('$lib/api', () => ({ vi.mock('$lib/api', () => ({
@@ -39,6 +41,8 @@ describe('route loaders use the SvelteKit fetch argument', () => {
vi.clearAllMocks(); vi.clearAllMocks();
sessionMocks.hasStoredClientSession.mockReturnValue(true); sessionMocks.hasStoredClientSession.mockReturnValue(true);
sessionMocks.hasStoredAdminSession.mockReturnValue(true); sessionMocks.hasStoredAdminSession.mockReturnValue(true);
sessionMocks.getStoredClientSession.mockReturnValue({ role: 'client', module_permissions: {} });
sessionMocks.hasModuleAccess.mockReturnValue(true);
apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]); apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]);
apiMocks.mixes.mockResolvedValue([{ id: 2 }]); apiMocks.mixes.mockResolvedValue([{ id: 2 }]);
@@ -48,7 +52,15 @@ describe('route loaders use the SvelteKit fetch argument', () => {
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]); apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]); apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]);
apiMocks.clientAccess.mockResolvedValue([{ id: 7 }]); apiMocks.clientAccess.mockResolvedValue([{ id: 7 }]);
apiMocks.clientAccessExport.mockResolvedValue({ generated_at: '', clients: [] }); apiMocks.clientAccessExport.mockResolvedValue({
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
});
}); });
it('passes fetch through the home page loader', async () => { it('passes fetch through the home page loader', async () => {
+4 -2
View File
@@ -1,4 +1,4 @@
import { hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load({ fetch }) { export async function load({ fetch }) {
@@ -8,9 +8,11 @@ export async function load({ fetch }) {
}; };
} }
const session = getStoredClientSession();
try { try {
return { return {
mixes: await api.mixes(fetch) mixes: hasModuleAccess(session, 'mix_master') ? await api.mixes(fetch) : []
}; };
} catch { } catch {
return { return {
+13 -2
View File
@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ params, fetch }) { export async function load({ params, fetch }) {
const mixId = Number(params.id); const mixId = Number(params.id);
@@ -16,8 +16,19 @@ export async function load({ params, fetch }) {
}; };
} }
const session = getStoredClientSession();
if (!hasModuleAccess(session, 'mix_master')) {
return {
mix: null,
rawMaterials: []
};
}
try { try {
const [mix, rawMaterials] = await Promise.all([api.mix(mixId, fetch), api.rawMaterials(fetch)]); const [mix, rawMaterials] = await Promise.all([
api.mix(mixId, fetch),
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([])
]);
return { return {
mix, mix,
+4 -2
View File
@@ -1,4 +1,4 @@
import { hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load({ fetch }) { export async function load({ fetch }) {
@@ -8,9 +8,11 @@ export async function load({ fetch }) {
}; };
} }
const session = getStoredClientSession();
try { try {
return { return {
rawMaterials: await api.rawMaterials(fetch) rawMaterials: hasModuleAccess(session, 'mix_master') && hasModuleAccess(session, 'raw_materials') ? await api.rawMaterials(fetch) : []
}; };
} catch { } catch {
return { return {
+7 -2
View File
@@ -1,4 +1,4 @@
import { hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load({ fetch }) { export async function load({ fetch }) {
@@ -9,8 +9,13 @@ export async function load({ fetch }) {
}; };
} }
const session = getStoredClientSession();
try { try {
const [products, productCosts] = await Promise.all([api.products(fetch), api.productCosts(fetch)]); const [products, productCosts] = await Promise.all([
hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
]);
return { return {
products, products,
productCosts productCosts
+7 -5
View File
@@ -1,4 +1,4 @@
import { hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load({ fetch }) { export async function load({ fetch }) {
@@ -11,12 +11,14 @@ export async function load({ fetch }) {
}; };
} }
const session = getStoredClientSession();
try { try {
const [rawMaterials, mixes, products, productCosts] = await Promise.all([ const [rawMaterials, mixes, products, productCosts] = await Promise.all([
api.rawMaterials(fetch), hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
api.mixes(fetch), hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
api.products(fetch), hasModuleAccess(session, 'products') ? api.products(fetch) : Promise.resolve([]),
api.productCosts(fetch) hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([])
]); ]);
return { return {
+4 -2
View File
@@ -1,4 +1,4 @@
import { hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
export async function load({ fetch }) { export async function load({ fetch }) {
@@ -8,9 +8,11 @@ export async function load({ fetch }) {
}; };
} }
const session = getStoredClientSession();
try { try {
return { return {
scenarios: await api.scenarios(fetch) scenarios: hasModuleAccess(session, 'scenarios') ? await api.scenarios(fetch) : []
}; };
} catch { } catch {
return { return {