Move working documents to its own area, rename dashboard
This commit is contained in:
+66
-11
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user