Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
2026-06-17 21:55:04 +12:00
parent 7db95e2027
commit 3f8279af10
24 changed files with 3820 additions and 37 deletions
+547 -19
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.core.access import ( from app.core.access import (
@@ -65,6 +65,9 @@ class RoleRead(BaseModel):
name: str name: str
description: str | None description: str | None
permissions: list[str] permissions: list[str]
module_permissions: dict[str, str]
is_protected: bool = False
user_count: int = 0
class UserRead(BaseModel): class UserRead(BaseModel):
@@ -73,6 +76,273 @@ class UserRead(BaseModel):
name: str name: str
is_active: bool is_active: bool
role: str | None role: str | None
role_id: int | None
# True when this user can never be deleted (lean owner accounts). The UI
# uses this to disable the delete control rather than re-deriving the rule.
is_protected: bool = False
class AssignableRole(BaseModel):
id: int
name: str
description: str | None
class RoleModuleDefinition(BaseModel):
key: str
label: str
description: str
levels: list[str]
class CreateUserRequest(BaseModel):
email: str
name: str
role_id: int | None = None
is_active: bool = True
password: str | None = None
class AdminUpdateUserRequest(BaseModel):
name: str | None = None
email: str | None = None
role_id: int | None = None
is_active: bool | None = None
class AdminSetPasswordRequest(BaseModel):
new_password: str
class CreateRoleRequest(BaseModel):
name: str
description: str | None = None
module_permissions: dict[str, str] = {}
class UpdateRoleRequest(BaseModel):
name: str | None = None
description: str | None = None
module_permissions: dict[str, str] | None = None
# Lean owner accounts are permanent: they may be edited but never deleted, so a
# tenant can't accidentally lock itself out of the highest level of access.
LEAN_ROLE_NAME = "lean"
ADMIN_ROLE_NAME = "admin"
ROLE_MANAGEMENT_ALLOWED_ROLES = {LEAN_ROLE_NAME, ADMIN_ROLE_NAME}
PROTECTED_ROLE_NAMES = ROLE_MANAGEMENT_ALLOWED_ROLES
ROLE_MODULE_DEFINITIONS: tuple[dict[str, object], ...] = (
{
"key": "dashboard",
"label": "Dashboard",
"description": "Home dashboard visibility.",
"levels": {"none": (), "view": ("view_dashboard",)},
},
{
"key": "mix_calculator",
"label": "Mix Calculator",
"description": "Open the calculator and save sessions.",
"levels": {
"none": (),
"view": ("view_mix_calculator",),
"edit": ("view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"),
},
},
{
"key": "raw_materials",
"label": "Raw Materials",
"description": "View or edit raw materials.",
"levels": {"none": (), "view": ("view_raw_materials",), "edit": ("view_raw_materials", "edit_raw_materials")},
},
{
"key": "products",
"label": "Products",
"description": "View or edit finished products.",
"levels": {"none": (), "view": ("view_products",), "edit": ("view_products", "edit_products")},
},
{
"key": "mix_master",
"label": "Mix Master",
"description": "View or edit mix recipes.",
"levels": {"none": (), "view": ("view_mixes",), "edit": ("view_mixes", "edit_mixes")},
},
{
"key": "operations_throughput",
"label": "Throughput",
"description": "View or edit throughput entries.",
"levels": {"none": (), "view": ("view_throughput",), "edit": ("view_throughput", "edit_throughput")},
},
{
"key": "ordering",
"label": "Ordering",
"description": "Access customer ordering and ordering administration.",
"levels": {
"none": (),
"view": ("view_ordering",),
"edit": ("view_ordering", "edit_ordering"),
"manage": ("view_ordering", "edit_ordering", "manage_ordering"),
},
},
{
"key": "scenarios",
"label": "Scenarios",
"description": "View or run scenarios.",
"levels": {"none": (), "view": ("view_scenarios",), "edit": ("view_scenarios", "edit_scenarios")},
},
{
"key": "client_access",
"label": "Client Access",
"description": "Manage customer portal accounts and access.",
"levels": {"none": (), "manage": ("manage_client_access",)},
},
{
"key": "users",
"label": "Users",
"description": "View or manage internal users.",
"levels": {"none": (), "view": ("view_users",), "manage": ("view_users", "manage_users")},
},
{
"key": "roles",
"label": "Roles",
"description": "Manage roles and permission assignments.",
"levels": {"none": (), "manage": ("manage_permissions",)},
},
{
"key": "settings",
"label": "Settings",
"description": "Open settings and edit system configuration.",
"levels": {"none": (), "view": ("view_settings",), "edit": ("view_settings", "edit_settings")},
},
)
def _serialize_user_read(user: User) -> UserRead:
role_name = user.role.name if user.role else None
return UserRead(
id=user.id,
email=user.email,
name=user.name,
is_active=user.is_active,
role=role_name,
role_id=user.role_id,
is_protected=(role_name or "").lower() == LEAN_ROLE_NAME,
)
def _role_name_lower(role: Role | None) -> str:
return (role.name if role else "").strip().lower()
def _is_protected_role_name(role_name: str | None) -> bool:
return (role_name or "").strip().lower() in PROTECTED_ROLE_NAMES
def _module_definitions_response() -> list[RoleModuleDefinition]:
return [
RoleModuleDefinition(
key=definition["key"],
label=definition["label"],
description=definition["description"],
levels=list(definition["levels"].keys()),
)
for definition in ROLE_MODULE_DEFINITIONS
]
def _permissions_to_role_module_map(permission_keys: set[str]) -> dict[str, str]:
result: dict[str, str] = {}
for definition in ROLE_MODULE_DEFINITIONS:
selected = "none"
levels = definition["levels"]
for level, required in levels.items():
required_keys = set(required)
if not required_keys or required_keys.issubset(permission_keys):
selected = level
result[definition["key"]] = selected
return result
def _role_payload_to_permission_keys(module_permissions: dict[str, str]) -> set[str]:
known_modules = {definition["key"] for definition in ROLE_MODULE_DEFINITIONS}
unknown_modules = sorted(set(module_permissions) - known_modules)
if unknown_modules:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown modules: {unknown_modules}",
)
granted: set[str] = set()
for definition in ROLE_MODULE_DEFINITIONS:
key = definition["key"]
level = module_permissions.get(key, "none")
available_levels: dict[str, tuple[str, ...]] = definition["levels"] # type: ignore[assignment]
if level not in available_levels:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid access level '{level}' for module '{key}'",
)
granted.update(available_levels[level])
return granted
def _serialize_role_read(role: Role, *, user_count: int = 0) -> RoleRead:
permission_keys = {permission.key for permission in role.permissions}
return RoleRead(
id=role.id,
name=role.name,
description=role.description,
permissions=sorted(permission_keys),
module_permissions=_permissions_to_role_module_map(permission_keys),
is_protected=_is_protected_role_name(role.name),
user_count=user_count,
)
def _require_role_management_actor(user: User = Depends(get_current_user)) -> User:
if _role_name_lower(user.role) not in ROLE_MANAGEMENT_ALLOWED_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only lean and admin accounts can manage roles",
)
return user
def _load_role(db: Session, role_id: int) -> Role:
role = db.scalar(
select(Role).where(Role.id == role_id).options(selectinload(Role.permissions))
)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
return role
def _apply_role_updates(
role: Role,
*,
name: str | None,
description: str | None,
module_permissions: dict[str, str] | None,
permissions_by_key: dict[str, Permission],
) -> None:
if name is not None:
trimmed_name = name.strip()
if not trimmed_name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
role.name = trimmed_name
if description is not None:
role.description = description.strip() or None
if module_permissions is not None:
desired_permission_keys = _role_payload_to_permission_keys(module_permissions)
desired = {permissions_by_key[key] for key in desired_permission_keys}
current = set(role.permissions)
for permission in desired - current:
role.permissions.append(permission)
for permission in current - desired:
role.permissions.remove(permission)
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession: def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
@@ -216,36 +486,294 @@ def list_users(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_permission("view_users")), # gated by permission key _: User = Depends(require_permission("view_users")), # gated by permission key
): ):
users = db.scalars(select(User).options(selectinload(User.role))).all() users = db.scalars(
select(User).options(selectinload(User.role)).order_by(User.name)
).all()
return [_serialize_user_read(user) for user in users]
@router.get("/assignable-roles", response_model=list[AssignableRole])
def list_assignable_roles(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Roles that a user-manager can assign — used to populate the role picker.
Separate from ``/roles`` (which exposes full permission sets and is gated by
``manage_permissions``); managing users only needs the role list itself.
"""
roles = db.scalars(select(Role).order_by(Role.name)).all()
return [ return [
UserRead( AssignableRole(id=role.id, name=role.name, description=role.description)
id=user.id, for role in roles
email=user.email,
name=user.name,
is_active=user.is_active,
role=user.role.name if user.role else None,
)
for user in users
] ]
def _load_managed_user(db: Session, user_id: int) -> User:
user = db.scalar(
select(User).where(User.id == user_id).options(selectinload(User.role))
)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
def _resolve_role(db: Session, role_id: int | None) -> Role | None:
if role_id is None:
return None
role = db.scalar(select(Role).where(Role.id == role_id))
if role is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown role")
return role
@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def create_user(
payload: CreateUserRequest,
db: Session = Depends(get_db),
actor: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Create a new internal user.
A user with no password can still sign in with the shared internal password
until they set a personal one in their own settings.
"""
email = payload.email.strip().lower()
if not email or "@" not in email:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
if db.scalar(select(User).where(User.email == email)):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
role = _resolve_role(db, payload.role_id)
password_hash = None
if payload.password:
if len(payload.password) < 8:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Password must be at least 8 characters",
)
password_hash = hash_password(payload.password)
user = User(
email=email,
name=name,
role_id=role.id if role else None,
is_active=payload.is_active,
password_hash=password_hash,
)
db.add(user)
db.commit()
db.refresh(user)
log_security_event("users.created", audience="internal", actor_user_id=actor.id, user_id=user.id)
return _serialize_user_read(user)
@router.patch("/users/{user_id}", response_model=UserRead)
def update_user(
user_id: int,
payload: AdminUpdateUserRequest,
db: Session = Depends(get_db),
actor: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Update another user's name, email, role, or active status."""
user = _load_managed_user(db, user_id)
if payload.is_active is not None:
# Guard against locking yourself out of your own management session.
if user.id == actor.id and not payload.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot deactivate your own account",
)
user.is_active = payload.is_active
if payload.name is not None:
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
user.name = name
if payload.email is not None:
email = payload.email.strip().lower()
if not email or "@" not in email:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
existing = db.scalar(select(User).where(User.email == email, User.id != user.id))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
user.email = email
if payload.role_id is not None:
role = _resolve_role(db, payload.role_id)
user.role_id = role.id if role else None
db.commit()
db.refresh(user)
log_security_event("users.updated", audience="internal", actor_user_id=actor.id, user_id=user.id)
return _serialize_user_read(user)
@router.post("/users/{user_id}/password", response_model=UserRead)
def set_user_password(
user_id: int,
payload: AdminSetPasswordRequest,
db: Session = Depends(get_db),
actor: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Set (reset) another user's password without their current password."""
user = _load_managed_user(db, user_id)
if len(payload.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="New password must be at least 8 characters",
)
user.password_hash = hash_password(payload.new_password)
db.commit()
db.refresh(user)
log_security_event("users.password_reset", audience="internal", actor_user_id=actor.id, user_id=user.id)
return _serialize_user_read(user)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
response: Response,
db: Session = Depends(get_db),
actor: User = Depends(require_permission("manage_users")), # gated by permission key
):
"""Delete a user. Lean owner accounts and your own account are protected."""
user = _load_managed_user(db, user_id)
if user.id == actor.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot delete your own account",
)
if (user.role.name if user.role else "").lower() == LEAN_ROLE_NAME:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Lean accounts cannot be deleted",
)
db.delete(user)
db.commit()
log_security_event("users.deleted", audience="internal", actor_user_id=actor.id, user_id=user_id)
response.status_code = status.HTTP_204_NO_CONTENT
return None
@router.get("/roles", response_model=list[RoleRead]) @router.get("/roles", response_model=list[RoleRead])
def list_roles( def list_roles(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_permissions")), # gated by permission key _: User = Depends(_require_role_management_actor),
): ):
user_counts = dict(
db.execute(select(User.role_id, func.count(User.id)).group_by(User.role_id)).all()
)
roles = db.scalars( roles = db.scalars(
select(Role).options(selectinload(Role.permissions)).order_by(Role.name) select(Role).options(selectinload(Role.permissions)).order_by(Role.name)
).all() ).all()
return [ return [_serialize_role_read(role, user_count=user_counts.get(role.id, 0)) for role in roles]
RoleRead(
id=role.id,
name=role.name, @router.get("/role-modules", response_model=list[RoleModuleDefinition])
description=role.description, def list_role_modules(_: User = Depends(_require_role_management_actor)):
permissions=sorted(p.key for p in role.permissions), return _module_definitions_response()
@router.post("/roles", response_model=RoleRead, status_code=status.HTTP_201_CREATED)
def create_role(
payload: CreateRoleRequest,
db: Session = Depends(get_db),
actor: User = Depends(_require_role_management_actor),
):
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
existing = db.scalar(select(Role).where(func.lower(Role.name) == name.lower()))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
role = Role(name=name, description=None)
db.add(role)
db.flush()
_apply_role_updates(
role,
name=name,
description=payload.description,
module_permissions=payload.module_permissions,
permissions_by_key=permissions_by_key,
)
db.commit()
db.refresh(role)
log_security_event("roles.created", audience="internal", actor_user_id=actor.id, role_id=role.id)
return _serialize_role_read(role, user_count=0)
@router.patch("/roles/{role_id}", response_model=RoleRead)
def update_role(
role_id: int,
payload: UpdateRoleRequest,
db: Session = Depends(get_db),
actor: User = Depends(_require_role_management_actor),
):
role = _load_role(db, role_id)
original_name = role.name
protected = _is_protected_role_name(original_name)
requested_name = payload.name.strip() if payload.name is not None else role.name
if protected and requested_name.lower() != original_name.lower():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Lean and admin roles cannot be renamed",
) )
for role in roles if payload.name is not None:
] duplicate = db.scalar(
select(Role).where(func.lower(Role.name) == requested_name.lower(), Role.id != role.id)
)
if duplicate:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
_apply_role_updates(
role,
name=payload.name,
description=payload.description,
module_permissions=payload.module_permissions,
permissions_by_key=permissions_by_key,
)
db.commit()
db.refresh(role)
user_count = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
log_security_event("roles.updated", audience="internal", actor_user_id=actor.id, role_id=role.id)
return _serialize_role_read(role, user_count=user_count)
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role(
role_id: int,
response: Response,
db: Session = Depends(get_db),
actor: User = Depends(_require_role_management_actor),
):
role = _load_role(db, role_id)
if _is_protected_role_name(role.name):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Lean and admin roles cannot be deleted",
)
assigned_users = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
if assigned_users > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reassign users before deleting this role",
)
db.delete(role)
db.commit()
log_security_event("roles.deleted", audience="internal", actor_user_id=actor.id, role_id=role_id)
response.status_code = status.HTTP_204_NO_CONTENT
return None
@router.get("/permissions", response_model=list[str]) @router.get("/permissions", response_model=list[str])
+208 -4
View File
@@ -8,7 +8,9 @@ from app.db.session import get_db
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial from app.models.raw_material import RawMaterial
from app.models.change_event import EditorChangeEvent
from app.schemas.editor import ( from app.schemas.editor import (
EditorChangeEventRead,
EditorIngredientCreate, EditorIngredientCreate,
EditorIngredientRow, EditorIngredientRow,
EditorIngredientUpdate, EditorIngredientUpdate,
@@ -25,6 +27,14 @@ from app.schemas.editor import (
EditorProductRow, EditorProductRow,
EditorProductUpdate, EditorProductUpdate,
EditorResolvedMixFormula, EditorResolvedMixFormula,
EditorResolvedMixIngredient,
)
from app.services.change_log import (
ENTITY_INGREDIENT,
ENTITY_MIX,
diff_fields,
list_changes,
record_change,
) )
from app.services.client_access_service import has_access_level from app.services.client_access_service import has_access_level
from app.services.costing_engine import calculate_raw_material_cost, get_active_price from app.services.costing_engine import calculate_raw_material_cost, get_active_price
@@ -126,6 +136,50 @@ def _serialize_mix_formula(mix: Mix) -> dict:
} }
def _serialize_change_event(event: EditorChangeEvent) -> dict:
return {
"id": event.id,
"entity_type": event.entity_type,
"entity_id": event.entity_id,
"action": event.action,
"actor_name": event.actor_name,
"actor_email": event.actor_email,
"actor_role": event.actor_role,
"summary": event.summary,
"changes": event.changes or [],
"created_at": event.created_at,
}
def _format_kg(value: float) -> str:
text = f"{value:.4f}".rstrip("0").rstrip(".")
return f"{text or '0'} kg"
def _formula_deltas(
before: list[EditorResolvedMixIngredient] | list,
after: list[EditorResolvedMixIngredient] | list,
) -> list[dict]:
"""Per-ingredient before/after deltas between two resolved formulas."""
before_map = {row.raw_material_name: row.quantity_kg for row in before}
after_map = {row.raw_material_name: row.quantity_kg for row in after}
deltas: list[dict] = []
for name in sorted(set(before_map) | set(after_map)):
old = before_map.get(name)
new = after_map.get(name)
if old == new:
continue
deltas.append(
{
"field": name,
"label": name,
"before": _format_kg(old) if old is not None else None,
"after": _format_kg(new) if new is not None else None,
}
)
return deltas
def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None: def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None:
return db.scalar( return db.scalar(
select(Mix) select(Mix)
@@ -267,6 +321,15 @@ def create_editor_mix(
notes=payload.notes, notes=payload.notes,
) )
db.add(mix) db.add(mix)
db.flush()
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix.id,
action="created",
summary=f"Created mix “{mix.name}” for {mix.client_name}",
)
db.commit() db.commit()
db.refresh(mix) db.refresh(mix)
# A brand-new mix has no products yet, so it reads as Inactive (no visible products). # A brand-new mix has no products yet, so it reads as Inactive (no visible products).
@@ -288,6 +351,16 @@ def update_editor_mix(
# `visible` is a virtual field: it fans out to the visibility of every product # `visible` is a virtual field: it fans out to the visibility of every product
# under the mix rather than mapping to a mix column. # under the mix rather than mapping to a mix column.
visible = updates.pop("visible", None) visible = updates.pop("visible", None)
before = {field: getattr(mix, field) for field in updates}
if visible is not None:
visible_before = db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
)
before["visible"] = bool(visible_before)
for field, value in updates.items(): for field, value in updates.items():
setattr(mix, field, value) setattr(mix, field, value)
@@ -297,6 +370,25 @@ def update_editor_mix(
).all(): ).all():
product.visible = visible product.visible = visible
after = dict(updates)
if visible is not None:
after["visible"] = visible
deltas = diff_fields(
before,
after,
{"name": "Mix name", "client_name": "Client", "notes": "Notes", "visible": "Status (active)"},
)
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix.id,
action="updated",
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
changes=deltas,
)
db.commit() db.commit()
counts = _mix_product_counts(db, session.tenant_id or "") counts = _mix_product_counts(db, session.tenant_id or "")
@@ -326,7 +418,10 @@ def add_editor_mix_ingredient(
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
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")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None: raw_material = db.scalar(
select(RawMaterial).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)
)
if raw_material is None:
raise HTTPException(status_code=404, detail="Raw material not found") raise HTTPException(status_code=404, detail="Raw material not found")
db.add( db.add(
@@ -338,6 +433,15 @@ def add_editor_mix_ingredient(
notes=payload.notes, notes=payload.notes,
) )
) )
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_added",
summary=f"Added {raw_material.name} ({_format_kg(payload.quantity_kg)})",
changes=[{"field": raw_material.name, "label": raw_material.name, "before": None, "after": _format_kg(payload.quantity_kg)}],
)
try: try:
db.commit() db.commit()
except IntegrityError as exc: except IntegrityError as exc:
@@ -367,8 +471,22 @@ def update_editor_mix_ingredient(
) )
if ingredient is None: if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found") raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items(): raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
updates = payload.model_dump(exclude_unset=True)
before = {field: getattr(ingredient, field) for field in updates}
for field, value in updates.items():
setattr(ingredient, field, value) setattr(ingredient, field, value)
deltas = diff_fields(before, updates, {"quantity_kg": f"{raw_material_name} quantity", "notes": f"{raw_material_name} notes"})
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_updated",
summary=f"Updated {raw_material_name}",
changes=deltas,
)
db.commit() db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
@@ -393,7 +511,18 @@ def delete_editor_mix_ingredient(
) )
if ingredient is None: if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found") raise HTTPException(status_code=404, detail="Ingredient not found")
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
removed_kg = ingredient.quantity_kg
db.delete(ingredient) db.delete(ingredient)
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_removed",
summary=f"Removed {raw_material_name}",
changes=[{"field": raw_material_name, "label": raw_material_name, "before": _format_kg(removed_kg), "after": None}],
)
db.commit() db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
@@ -437,6 +566,9 @@ def replace_editor_mix_formula(
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")
# Snapshot the formula as it stands so we can diff it against the saved one.
before_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
raw_ids = [row.raw_material_id for row in payload.rows] raw_ids = [row.raw_material_id for row in payload.rows]
if len(set(raw_ids)) != len(raw_ids): if len(set(raw_ids)) != len(raw_ids):
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix") raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
@@ -483,9 +615,35 @@ def replace_editor_mix_formula(
) )
) )
db.commit() db.flush()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
deltas = _formula_deltas(before_formula.ingredients, after_formula.ingredients)
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="formula_updated",
summary=f"Updated formula ({len(deltas)} ingredient {'change' if len(deltas) == 1 else 'changes'})",
changes=deltas,
)
db.commit()
return after_formula
@router.get("/mixes/{mix_id}/history", response_model=list[EditorChangeEventRead])
def get_editor_mix_history(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_MIX, entity_id=mix_id)
return [_serialize_change_event(event) for event in events]
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead) @router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
@@ -662,6 +820,15 @@ def create_editor_ingredient(
) )
db.add(material) db.add(material)
try: try:
db.flush()
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=material.id,
action="created",
summary=f"Created ingredient “{material.name}",
)
db.commit() db.commit()
except IntegrityError as exc: except IntegrityError as exc:
db.rollback() db.rollback()
@@ -692,8 +859,32 @@ def update_editor_ingredient(
updates["supplier"] = (updates["supplier"] or "").strip() or None updates["supplier"] = (updates["supplier"] or "").strip() or None
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None: if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
updates["unit_of_measure"] = updates["unit_of_measure"].strip() updates["unit_of_measure"] = updates["unit_of_measure"].strip()
before = {field: getattr(material, field) for field in updates}
for field, value in updates.items(): for field, value in updates.items():
setattr(material, field, value) setattr(material, field, value)
deltas = diff_fields(
before,
updates,
{
"name": "Name",
"supplier": "Supplier",
"unit_of_measure": "Unit of measure",
"kg_per_unit": "Kg per unit",
"status": "Status",
"rounding_decimals": "Rounding (dp)",
"notes": "Notes",
},
)
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=material.id,
action="updated",
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
changes=deltas,
)
try: try:
db.commit() db.commit()
except IntegrityError as exc: except IntegrityError as exc:
@@ -702,3 +893,16 @@ def update_editor_ingredient(
db.refresh(material) db.refresh(material)
usage = _ingredient_usage_counts(db, tenant_id) usage = _ingredient_usage_counts(db, tenant_id)
return _serialize_ingredient(material, usage.get(material.id, 0)) return _serialize_ingredient(material, usage.get(material.id, 0))
@router.get("/ingredients/{ingredient_id}/history", response_model=list[EditorChangeEventRead])
def get_editor_ingredient_history(
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)) is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_INGREDIENT, entity_id=ingredient_id)
return [_serialize_change_event(event) for event in events]
+16
View File
@@ -10,6 +10,7 @@ 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.throughput import ProductionThroughput, ThroughputProduct from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.schemas.throughput import ( from app.schemas.throughput import (
ThroughputDeleteAllResult,
ThroughputEntryCreate, ThroughputEntryCreate,
ThroughputEntryRead, ThroughputEntryRead,
ThroughputEntryUpdate, ThroughputEntryUpdate,
@@ -216,6 +217,21 @@ def import_entries(
return result return result
@router.delete("/entries", response_model=ThroughputDeleteAllResult)
def delete_all_entries(
# Clearing the log is part of correcting a bad import, so it sits at the same
# "edit" level as deleting a single entry. It is scoped to the caller's
# tenant, so one client can never wipe another's data.
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
deleted = db.query(ProductionThroughput).filter(
ProductionThroughput.tenant_id == session.tenant_id
).delete(synchronize_session=False)
db.commit()
return ThroughputDeleteAllResult(entries_deleted=deleted)
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead) @router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
def get_entry( def get_entry(
entry_id: int, entry_id: int,
+1
View File
@@ -37,6 +37,7 @@ TENANT_TABLES = {
"product_cost_freight_inputs": None, "product_cost_freight_inputs": None,
"scenarios": None, "scenarios": None,
"costing_results": None, "costing_results": None,
"editor_change_events": None,
"process_cost_rules": None, "process_cost_rules": None,
"packaging_cost_rules": None, "packaging_cost_rules": None,
"freight_cost_rules": None, "freight_cost_rules": None,
+2
View File
@@ -1,5 +1,6 @@
from app.models.access import Permission, Role, User, role_permissions from app.models.access import Permission, Role, User, role_permissions
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.change_event import EditorChangeEvent
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
@@ -40,6 +41,7 @@ __all__ = [
"ClientUser", "ClientUser",
"ClientUserModulePermission", "ClientUserModulePermission",
"CostingResult", "CostingResult",
"EditorChangeEvent",
"CustomerPriceAssignment", "CustomerPriceAssignment",
"CustomerProductPrice", "CustomerProductPrice",
"CustomerProductVisibility", "CustomerProductVisibility",
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class EditorChangeEvent(Base):
"""An audit row recording an edit to a mix or an ingredient.
Written by the editor API whenever a mix or raw material (ingredient) is
created or changed, and read back per-entity by the History buttons on the
Mix Editor and Ingredients Editor. `changes` holds a list of
``{"field", "label", "before", "after"}`` field deltas so the UI can show a
readable before/after for each edit.
"""
__tablename__ = "editor_change_events"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
# "mix" or "ingredient" — the surface the History button lives on.
entity_type: Mapped[str] = mapped_column(String(32), index=True)
entity_id: Mapped[int] = mapped_column(Integer, index=True)
action: Mapped[str] = mapped_column(String(48))
actor_name: Mapped[str] = mapped_column(String(255), default="")
actor_email: Mapped[str] = mapped_column(String(255), default="")
actor_role: Mapped[str | None] = mapped_column(String(64), nullable=True)
summary: Mapped[str] = mapped_column(Text, default="")
changes: Mapped[list | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
+23
View File
@@ -219,3 +219,26 @@ class EditorIngredientUpdate(BaseModel):
status: str | None = Field(default=None, max_length=32) status: str | None = Field(default=None, max_length=32)
rounding_decimals: int | None = Field(default=None, ge=0, le=6) rounding_decimals: int | None = Field(default=None, ge=0, le=6)
notes: str | None = Field(default=None, max_length=2000) notes: str | None = Field(default=None, max_length=2000)
# --- Change history ----------------------------------------------------------
class EditorChangeFieldDelta(BaseModel):
field: str
label: str
before: str | None = None
after: str | None = None
class EditorChangeEventRead(BaseModel):
id: int
entity_type: str
entity_id: int
action: str
actor_name: str
actor_email: str
actor_role: str | None
summary: str
changes: list[EditorChangeFieldDelta]
created_at: datetime
+4
View File
@@ -124,6 +124,10 @@ class ThroughputImportResult(BaseModel):
errors: list[str] = Field(default_factory=list) errors: list[str] = Field(default_factory=list)
class ThroughputDeleteAllResult(BaseModel):
entries_deleted: int
class ThroughputEntryRead(BaseModel): class ThroughputEntryRead(BaseModel):
id: int id: int
tenant_id: str tenant_id: str
+97
View File
@@ -0,0 +1,97 @@
"""Recording and reading editor change history.
The Mix Editor and Ingredients Editor write a row here on every create/edit so
each mix and ingredient carries an auditable history (who changed what, when).
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession
from app.models.change_event import EditorChangeEvent
# Entity types — these match the History buttons on the two editors.
ENTITY_MIX = "mix"
ENTITY_INGREDIENT = "ingredient"
def _stringify(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, bool):
return "Yes" if value else "No"
if isinstance(value, float):
# Trim trailing zeros so 12.50 reads as 12.5 and 12.0 as 12.
text = f"{value:.4f}".rstrip("0").rstrip(".")
return text or "0"
return str(value)
def diff_fields(before: dict[str, Any], after: dict[str, Any], labels: dict[str, str]) -> list[dict[str, Any]]:
"""Build a list of ``{field, label, before, after}`` deltas for changed fields.
Only keys present in ``labels`` are considered, and only those whose value
actually changed are emitted.
"""
deltas: list[dict[str, Any]] = []
for field, label in labels.items():
if field not in after:
continue
old = before.get(field)
new = after.get(field)
if old == new:
continue
deltas.append({"field": field, "label": label, "before": _stringify(old), "after": _stringify(new)})
return deltas
def record_change(
db: Session,
*,
session: AuthSession,
entity_type: str,
entity_id: int,
action: str,
summary: str,
changes: list[dict[str, Any]] | None = None,
) -> None:
"""Append a change event. Caller is responsible for committing the session."""
db.add(
EditorChangeEvent(
tenant_id=session.tenant_id or "",
entity_type=entity_type,
entity_id=entity_id,
action=action,
actor_name=session.name or session.email or "Unknown",
actor_email=session.email or "",
actor_role=session.client_role or session.role,
summary=summary,
changes=changes or [],
)
)
def list_changes(
db: Session,
*,
tenant_id: str,
entity_type: str,
entity_id: int,
limit: int = 200,
) -> list[EditorChangeEvent]:
return list(
db.scalars(
select(EditorChangeEvent)
.where(
EditorChangeEvent.tenant_id == tenant_id,
EditorChangeEvent.entity_type == entity_type,
EditorChangeEvent.entity_id == entity_id,
)
.order_by(EditorChangeEvent.created_at.desc(), EditorChangeEvent.id.desc())
.limit(limit)
).all()
)
+44 -3
View File
@@ -4,6 +4,7 @@ import csv
import io import io
import logging import logging
import os import os
import re
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
@@ -149,7 +150,17 @@ def _coerce_text(value: object) -> str | None:
return text return text
def _coerce_date(value: object) -> date | None: # Default slash-date preference. The app is Australian, so an ambiguous
# "x/y/z" is read day-first unless a column is detected as month-first.
_DAY_FIRST_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y")
_MONTH_FIRST_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y")
_SLASH_DATE_RE = re.compile(r"^\s*(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})\s*$")
def _coerce_date(
value: object, formats: tuple[str, ...] = _DAY_FIRST_FORMATS
) -> date | None:
if value is None: if value is None:
return None return None
if isinstance(value, datetime): if isinstance(value, datetime):
@@ -159,7 +170,7 @@ def _coerce_date(value: object) -> date | None:
text = str(value).strip() text = str(value).strip()
if not text: if not text:
return None return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"): for fmt in formats:
try: try:
return datetime.strptime(text, fmt).date() return datetime.strptime(text, fmt).date()
except ValueError: except ValueError:
@@ -167,6 +178,32 @@ def _coerce_date(value: object) -> date | None:
return None return None
def _detect_slash_date_formats(values: Iterable[object]) -> tuple[str, ...]:
"""Inspect every slash/dash date in a column and decide whether the file is
day-first (D/M/Y) or month-first (M/D/Y), so all rows parse consistently.
A first component > 12 proves day-first; a second component > 12 proves
month-first. If only month-first evidence exists we switch to M/D/Y;
otherwise we keep the Australian day-first default.
"""
day_first = False
month_first = False
for value in values:
if value is None or isinstance(value, (datetime, date)):
continue
match = _SLASH_DATE_RE.match(str(value))
if not match:
continue
first, second = int(match.group(1)), int(match.group(2))
if first > 12:
day_first = True
elif second > 12:
month_first = True
if month_first and not day_first:
return _MONTH_FIRST_FORMATS
return _DAY_FIRST_FORMATS
def _infer_bulka_default(name: str, bag_size: float | None) -> bool: def _infer_bulka_default(name: str, bag_size: float | None) -> bool:
lowered = name.lower() lowered = name.lower()
if "bulka" in lowered: if "bulka" in lowered:
@@ -571,6 +608,10 @@ def import_entries_from_file(
return None return None
return row[idx] return row[idx]
# Decide the slash-date order once for the whole file so ambiguous values
# like "12/9/2025" follow the same convention as the unambiguous ones.
date_formats = _detect_slash_date_formats(cell(row, "date") for row in data_rows)
# Index existing products for matching (by item_id and by lower-cased name). # Index existing products for matching (by item_id and by lower-cased name).
by_item: dict[str, ThroughputProduct] = {} by_item: dict[str, ThroughputProduct] = {}
by_name: dict[str, ThroughputProduct] = {} by_name: dict[str, ThroughputProduct] = {}
@@ -596,7 +637,7 @@ def import_entries_from_file(
if not row or all(value is None or str(value).strip() == "" for value in row): if not row or all(value is None or str(value).strip() == "" for value in row):
continue continue
production_date = _coerce_date(cell(row, "date")) production_date = _coerce_date(cell(row, "date"), date_formats)
product_name = _coerce_text(cell(row, "product")) product_name = _coerce_text(cell(row, "product"))
quantity = _coerce_float(cell(row, "quantity")) quantity = _coerce_float(cell(row, "quantity"))
+3 -3
View File
@@ -3,9 +3,9 @@ requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "data-entry-app-backend" name = "hunter-backend"
version = "0.1.19" version = "0.1.27"
description = "Costing platform MVP backend" description = "Costing platform MVP backend (API for Hunter)"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.115,<1.0", "fastapi>=0.115,<1.0",
+204
View File
@@ -347,3 +347,207 @@ def test_internal_user_can_change_own_password(access_app_and_db):
json={"email": admin.email, "password": "new-personal-password"}, json={"email": admin.email, "password": "new-personal-password"},
) )
assert new_login.status_code == 200 assert new_login.status_code == 200
# --- Admin user management --------------------------------------------------
def _admin_headers(db: Session) -> dict[str, str]:
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
return {"Authorization": f"Bearer {_token_for(admin)}"}
def test_manage_users_create_update_password_delete(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
full_access_role = db.query(Role).filter_by(name="Full Access").one()
created = client.post(
"/api/access/users",
json={"email": "new.user@hunterstockfeeds.com", "name": "New User", "role_id": full_access_role.id},
headers=headers,
)
assert created.status_code == 201
body = created.json()
assert body["email"] == "new.user@hunterstockfeeds.com"
assert body["role"] == "Full Access"
assert body["is_protected"] is False
user_id = body["id"]
operations_role = db.query(Role).filter_by(name="Operations").one()
updated = client.patch(
f"/api/access/users/{user_id}",
json={"name": "Renamed", "role_id": operations_role.id, "is_active": False},
headers=headers,
)
assert updated.status_code == 200
assert updated.json()["name"] == "Renamed"
assert updated.json()["role"] == "Operations"
assert updated.json()["is_active"] is False
pw = client.post(
f"/api/access/users/{user_id}/password",
json={"new_password": "brand-new-pass"},
headers=headers,
)
assert pw.status_code == 200
db.expire_all()
target = db.query(User).filter_by(id=user_id).one()
assert verify_password("brand-new-pass", target.password_hash)
deleted = client.delete(f"/api/access/users/{user_id}", headers=headers)
assert deleted.status_code == 204
assert db.query(User).filter_by(id=user_id).one_or_none() is None
def test_create_user_rejects_duplicate_email(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
response = client.post(
"/api/access/users",
json={"email": "admin@hunterstockfeeds.com", "name": "Dup"},
headers=headers,
)
assert response.status_code == 409
def test_manage_users_requires_permission(access_app_and_db):
client, db = access_app_and_db
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
response = client.post(
"/api/access/users",
json={"email": "x@hunterstockfeeds.com", "name": "X"},
headers=headers,
)
assert response.status_code == 403
def test_cannot_deactivate_or_delete_self(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(admin)}"}
deactivate = client.patch(
f"/api/access/users/{admin.id}", json={"is_active": False}, headers=headers
)
assert deactivate.status_code == 400
delete = client.delete(f"/api/access/users/{admin.id}", headers=headers)
assert delete.status_code == 400
def test_lean_users_cannot_be_deleted(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
lean_role = db.query(Role).filter_by(name="lean").one()
lean_user = User(email="owner@hunterstockfeeds.com", name="Owner", role_id=lean_role.id, is_active=True)
db.add(lean_user)
db.commit()
listed = client.get("/api/access/users", headers=headers)
assert listed.status_code == 200
owner_row = next(row for row in listed.json() if row["id"] == lean_user.id)
assert owner_row["is_protected"] is True
response = client.delete(f"/api/access/users/{lean_user.id}", headers=headers)
assert response.status_code == 403
assert db.query(User).filter_by(id=lean_user.id).one_or_none() is not None
def test_assignable_roles_lists_all_roles(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
response = client.get("/api/access/assignable-roles", headers=headers)
assert response.status_code == 200
names = {row["name"] for row in response.json()}
assert names == set(ROLE_DEFINITIONS.keys())
def test_role_management_lists_modules_and_roles_for_admin(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
modules = client.get("/api/access/role-modules", headers=headers)
assert modules.status_code == 200
module_keys = {row["key"] for row in modules.json()}
assert {"dashboard", "mix_calculator", "roles", "settings"} <= module_keys
roles = client.get("/api/access/roles", headers=headers)
assert roles.status_code == 200
admin_role = next(row for row in roles.json() if row["name"] == "Admin")
assert admin_role["is_protected"] is True
assert admin_role["module_permissions"]["ordering"] == "manage"
assert admin_role["module_permissions"]["roles"] == "manage"
def test_role_management_is_blocked_for_non_admin_non_lean_roles(access_app_and_db):
client, db = access_app_and_db
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
response = client.get("/api/access/roles", headers=headers)
assert response.status_code == 403
assert "lean and admin" in response.json()["detail"]
def test_role_management_create_update_delete_custom_role(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
created = client.post(
"/api/access/roles",
json={
"name": "Reporting Viewer",
"description": "Can review dashboards and reporting inputs",
"module_permissions": {
"dashboard": "view",
"products": "view",
"settings": "view",
},
},
headers=headers,
)
assert created.status_code == 201
created_body = created.json()
assert created_body["module_permissions"]["dashboard"] == "view"
assert created_body["module_permissions"]["products"] == "view"
assert created_body["module_permissions"]["settings"] == "view"
role_id = created_body["id"]
updated = client.patch(
f"/api/access/roles/{role_id}",
json={
"description": "Can review and edit product data",
"module_permissions": {
"dashboard": "view",
"products": "edit",
"settings": "view",
},
},
headers=headers,
)
assert updated.status_code == 200
updated_body = updated.json()
assert updated_body["module_permissions"]["products"] == "edit"
assert "edit_products" in updated_body["permissions"]
deleted = client.delete(f"/api/access/roles/{role_id}", headers=headers)
assert deleted.status_code == 204
assert db.query(Role).filter_by(id=role_id).one_or_none() is None
def test_protected_or_assigned_roles_cannot_be_deleted(access_app_and_db):
client, db = access_app_and_db
headers = _admin_headers(db)
admin_role = db.query(Role).filter_by(name="Admin").one()
protected = client.delete(f"/api/access/roles/{admin_role.id}", headers=headers)
assert protected.status_code == 403
full_access_role = db.query(Role).filter_by(name="Full Access").one()
assigned = client.delete(f"/api/access/roles/{full_access_role.id}", headers=headers)
assert assigned.status_code == 400
+88
View File
@@ -0,0 +1,88 @@
"""The change log records who edited a mix/ingredient and what changed.
Covers `record_change` / `diff_fields` / `list_changes`: edits are stored with a
field-level before/after diff and read back newest-first per entity.
"""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import AuthSession
from app.db.session import Base
from app.services.change_log import (
ENTITY_INGREDIENT,
ENTITY_MIX,
diff_fields,
list_changes,
record_change,
)
TENANT = "hunter-premium-produce"
LABELS = {"name": "Name", "kg_per_unit": "Kg per unit", "status": "Status"}
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _actor() -> AuthSession:
return AuthSession(role="internal", email="lara@hunter.test", name="Lara", tenant_id=TENANT, client_role="admin")
def test_diff_fields_only_emits_changed_keys():
deltas = diff_fields({"name": "Maize", "kg_per_unit": 25.0}, {"name": "Maize", "kg_per_unit": 30.0}, LABELS)
assert len(deltas) == 1
assert deltas[0]["field"] == "kg_per_unit"
assert deltas[0]["before"] == "25"
assert deltas[0]["after"] == "30"
def test_records_and_lists_changes_newest_first():
db = _session()
session = _actor()
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=7,
action="created",
summary="Created ingredient “Maize”",
)
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=7,
action="updated",
summary="Updated Kg per unit",
changes=diff_fields({"kg_per_unit": 25.0}, {"kg_per_unit": 30.0}, LABELS),
)
# A different entity must not leak into entity 7's history.
record_change(db, session=session, entity_type=ENTITY_MIX, entity_id=7, action="created", summary="Created mix")
db.commit()
events = list_changes(db, tenant_id=TENANT, entity_type=ENTITY_INGREDIENT, entity_id=7)
assert [event.action for event in events] == ["updated", "created"]
assert events[0].actor_name == "Lara"
assert events[0].actor_role == "admin"
assert events[0].changes[0]["label"] == "Kg per unit"
def test_changes_are_tenant_scoped():
db = _session()
record_change(
db,
session=AuthSession(role="internal", email="x@y.test", name="X", tenant_id="other-tenant"),
entity_type=ENTITY_MIX,
entity_id=1,
action="created",
summary="Created mix",
)
db.commit()
assert list_changes(db, tenant_id=TENANT, entity_type=ENTITY_MIX, entity_id=1) == []
+54
View File
@@ -266,6 +266,60 @@ def test_upload_import_keeps_blank_destination_flags_false():
assert entry.job_number is None assert entry.job_number is None
def test_upload_import_detects_month_first_dates_consistently():
# The pasted sheet is US month-first (M/D/Y). "9/23/2025" is unambiguous, so
# the ambiguous "12/9/2025" must follow the same convention: 9 December, not
# 12 September (which the old day-first-by-default parser produced).
db = _session()
csv_bytes = (
"Date,Product,Item ID,Quantity,Type,Bag Size,Packed By\n"
"12/9/2025,Whole Wheat Cleaned & Graded 20kg,373022,156,bags,20,Jake\n"
"9/23/2025,Steam Rolled Barley 20kg,568240,34,bags,20,jake\n"
"9/24/2025,Stock Mix 20kg,540725,153,bags,20,jake\n"
).encode("utf-8")
result = import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
assert result["entries_imported"] == 3
dates = {
e.product_name_snapshot: e.production_date
for e in db.scalars(select(ProductionThroughput)).all()
}
assert dates["Whole Wheat Cleaned & Graded 20kg"] == date(2025, 12, 9)
assert dates["Steam Rolled Barley 20kg"] == date(2025, 9, 23)
assert dates["Stock Mix 20kg"] == date(2025, 9, 24)
def test_upload_import_keeps_day_first_dates_for_australian_sheets():
# A genuinely day-first file (23/9/2025 proves D/M/Y) must stay day-first, so
# 12/9/2025 reads as 12 September.
db = _session()
csv_bytes = (
"Date,Product,Quantity,Type,Bag Size\n"
"23/9/2025,Stock Mix 20kg,10,bags,20\n"
"12/9/2025,Stock Mix 20kg,10,bags,20\n"
).encode("utf-8")
import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
produced = sorted(
e.production_date for e in db.scalars(select(ProductionThroughput)).all()
)
assert produced == [date(2025, 9, 12), date(2025, 9, 23)]
def test_upload_import_does_not_treat_unknown_destination_text_as_true(): def test_upload_import_does_not_treat_unknown_destination_text_as_true():
db = _session() db = _session()
csv_bytes = ( csv_bytes = (
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.26", "version": "0.1.27",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.26", "version": "0.1.27",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1" "lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.26", "version": "0.1.27",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+54
View File
@@ -7,6 +7,14 @@ import type {
ClientUserCreateInput, ClientUserCreateInput,
ClientUserModulePermission, ClientUserModulePermission,
ClientUserUpdateInput, ClientUserUpdateInput,
InternalUser,
InternalRoleOption,
InternalRole,
InternalRoleCreateInput,
InternalRoleModuleDefinition,
InternalRoleUpdateInput,
InternalUserCreateInput,
InternalUserUpdateInput,
LoginResponse, LoginResponse,
EditorMixCreateInput, EditorMixCreateInput,
EditorMixUpdateInput, EditorMixUpdateInput,
@@ -17,6 +25,7 @@ import type {
EditorIngredientRow, EditorIngredientRow,
EditorIngredientCreateInput, EditorIngredientCreateInput,
EditorIngredientUpdateInput, EditorIngredientUpdateInput,
EditorChangeEvent,
EditorProductFormula, EditorProductFormula,
EditorProductRow, EditorProductRow,
EditorProductUpdateInput, EditorProductUpdateInput,
@@ -49,6 +58,7 @@ import type {
XeroContactList, XeroContactList,
XeroContactLinkRow, XeroContactLinkRow,
Scenario, Scenario,
ThroughputDeleteAllResult,
ThroughputEntry, ThroughputEntry,
ThroughputEntryCreateInput, ThroughputEntryCreateInput,
ThroughputEntryUpdateInput, ThroughputEntryUpdateInput,
@@ -453,6 +463,10 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
editorMixHistory: (mixId: number) =>
request<EditorChangeEvent[]>(`/api/editor/mixes/${mixId}/history`, {}, 'client'),
editorIngredientHistory: (ingredientId: number) =>
request<EditorChangeEvent[]>(`/api/editor/ingredients/${ingredientId}/history`, {}, 'client'),
productCosts: (fetcher?: ApiFetch) => productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher), cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
productCostingItems: (fetcher?: ApiFetch) => productCostingItems: (fetcher?: ApiFetch) =>
@@ -505,6 +519,8 @@ export const api = {
formData.append('file', file); formData.append('file', file);
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client'); return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
}, },
deleteAllThroughputEntries: () =>
request<ThroughputDeleteAllResult>('/api/throughput/entries', { method: 'DELETE' }, 'client'),
createThroughputProduct: (payload: ThroughputProductCreateInput) => createThroughputProduct: (payload: ThroughputProductCreateInput) =>
request<ThroughputProduct>('/api/throughput/products', { request<ThroughputProduct>('/api/throughput/products', {
method: 'POST', method: 'POST',
@@ -541,6 +557,44 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
// --- Internal user management (lean/admin: manage_users) ------------------
accessUsers: (fetcher?: ApiFetch) =>
request<InternalUser[]>('/api/access/users', { method: 'GET' }, 'client', fetcher),
accessAssignableRoles: (fetcher?: ApiFetch) =>
request<InternalRoleOption[]>('/api/access/assignable-roles', { method: 'GET' }, 'client', fetcher),
accessRoles: (fetcher?: ApiFetch) =>
request<InternalRole[]>('/api/access/roles', { method: 'GET' }, 'client', fetcher),
accessRoleModules: (fetcher?: ApiFetch) =>
request<InternalRoleModuleDefinition[]>('/api/access/role-modules', { method: 'GET' }, 'client', fetcher),
createAccessRole: (payload: InternalRoleCreateInput) =>
request<InternalRole>('/api/access/roles', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateAccessRole: (roleId: number, payload: InternalRoleUpdateInput) =>
request<InternalRole>(`/api/access/roles/${roleId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteAccessRole: (roleId: number) =>
request<void>(`/api/access/roles/${roleId}`, { method: 'DELETE' }, 'client'),
createAccessUser: (payload: InternalUserCreateInput) =>
request<InternalUser>('/api/access/users', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateAccessUser: (userId: number, payload: InternalUserUpdateInput) =>
request<InternalUser>(`/api/access/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
setAccessUserPassword: (userId: number, newPassword: string) =>
request<InternalUser>(`/api/access/users/${userId}/password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword })
}, 'client'),
deleteAccessUser: (userId: number) =>
request<void>(`/api/access/users/${userId}`, { method: 'DELETE' }, 'client'),
adminLogin: (email: string, password: string) => adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', { request<LoginResponse>('/api/auth/admin/login', {
method: 'POST', method: 'POST',
@@ -0,0 +1,353 @@
<script lang="ts">
import { api } from '$lib/api';
import type { EditorChangeEvent } from '$lib/types';
import { Clock, History, X } from 'lucide-svelte';
import { onMount } from 'svelte';
let {
entityType,
entityId,
title,
subtitle = '',
onClose
}: {
entityType: 'mix' | 'ingredient';
entityId: number;
title: string;
subtitle?: string;
onClose: () => void;
} = $props();
let events = $state<EditorChangeEvent[] | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
events =
entityType === 'mix'
? await api.editorMixHistory(entityId)
: await api.editorIngredientHistory(entityId);
} catch (err) {
error = err instanceof Error ? err.message : 'Unable to load history';
}
});
const ACTION_LABELS: Record<string, string> = {
created: 'Created',
updated: 'Updated',
formula_updated: 'Formula updated',
ingredient_added: 'Ingredient added',
ingredient_updated: 'Ingredient updated',
ingredient_removed: 'Ingredient removed'
};
function actionLabel(action: string) {
return ACTION_LABELS[action] ?? action.replace(/_/g, ' ');
}
function formatWhen(value: string) {
// Stored as a naive UTC timestamp; treat it as UTC for display.
const iso = value.endsWith('Z') || value.includes('+') ? value : `${value}Z`;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<div class="history-backdrop" role="presentation" onclick={onClose}>
<div
class="history-modal"
role="dialog"
aria-modal="true"
aria-label={`Change history for ${title}`}
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') onClose();
}}
>
<header class="history-head">
<div class="history-title">
<span class="kicker"><History size={14} strokeWidth={2.2} /> Change history</span>
<h3>{title}</h3>
{#if subtitle}<p class="subtitle">{subtitle}</p>{/if}
</div>
<button class="icon-close" type="button" onclick={onClose} aria-label="Close history">
<X size={18} strokeWidth={2.2} />
</button>
</header>
<div class="history-body">
{#if error}
<p class="state error">{error}</p>
{:else if events === null}
<p class="state">Loading history…</p>
{:else if events.length === 0}
<div class="empty">
<Clock size={22} strokeWidth={1.8} />
<p>No changes recorded yet.</p>
<span>Edits made from here on will appear in this list.</span>
</div>
{:else}
<ol class="timeline">
{#each events as event (event.id)}
<li class="event">
<div class="event-head">
<span class="badge">{actionLabel(event.action)}</span>
<time>{formatWhen(event.created_at)}</time>
</div>
<p class="summary">{event.summary}</p>
{#if event.changes.length}
<ul class="deltas">
{#each event.changes as delta}
<li>
<span class="delta-label">{delta.label}</span>
<span class="delta-values">
<span class="before">{delta.before ?? '—'}</span>
<span class="arrow" aria-hidden="true"></span>
<span class="after">{delta.after ?? '—'}</span>
</span>
</li>
{/each}
</ul>
{/if}
<p class="actor">by {event.actor_name}{#if event.actor_role} · {event.actor_role}{/if}</p>
</li>
{/each}
</ol>
{/if}
</div>
</div>
</div>
<style>
h3,
p {
margin: 0;
}
.history-backdrop {
position: fixed;
inset: 0;
z-index: 70;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(17, 24, 20, 0.52);
backdrop-filter: blur(8px);
}
.history-modal {
display: flex;
flex-direction: column;
width: min(620px, 100%);
max-height: calc(100vh - 2rem);
border: 1px solid var(--color-border);
border-radius: 0.9rem;
background: var(--color-bg-surface);
overflow: hidden;
}
.history-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-app);
}
.history-title {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.kicker {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.history-title h3 {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text-primary);
}
.subtitle {
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.icon-close {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 34px;
height: 34px;
border: 1px solid var(--color-border);
border-radius: 0.45rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
}
.icon-close:hover {
border-color: var(--color-text-muted);
color: var(--color-text-primary);
}
.history-body {
padding: 1rem 1.1rem 1.2rem;
overflow-y: auto;
}
.state {
padding: 1.5rem 0;
text-align: center;
color: var(--color-text-secondary);
font-weight: 600;
}
.state.error {
color: var(--color-error);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 2rem 1rem;
text-align: center;
color: var(--color-text-secondary);
}
.empty p {
font-weight: 650;
color: var(--color-text-primary);
}
.empty span {
font-size: 0.84rem;
}
.timeline {
display: flex;
flex-direction: column;
gap: 0.7rem;
margin: 0;
padding: 0;
list-style: none;
}
.event {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.8rem 0.9rem;
border: 1px solid var(--color-divider);
border-radius: 0.6rem;
background: var(--color-bg-app);
}
.event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem;
border-radius: 999px;
background: var(--color-brand-tint);
color: var(--color-brand);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.event-head time {
color: var(--color-text-muted);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.summary {
color: var(--color-text-primary);
font-size: 0.9rem;
font-weight: 600;
}
.deltas {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0.1rem 0 0;
padding: 0.5rem 0.6rem;
list-style: none;
border-radius: 0.45rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-divider);
}
.deltas li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.delta-label {
color: var(--color-text-secondary);
font-size: 0.82rem;
font-weight: 600;
}
.delta-values {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.before {
color: var(--color-text-muted);
text-decoration: line-through;
}
.arrow {
color: var(--color-text-muted);
}
.after {
color: var(--color-text-primary);
font-weight: 700;
}
.actor {
color: var(--color-text-muted);
font-size: 0.78rem;
}
</style>
@@ -0,0 +1,792 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type {
InternalRole,
InternalRoleCreateInput,
InternalRoleModuleDefinition,
InternalRoleUpdateInput
} from '$lib/types';
import { Pencil, ShieldCheck, Trash2, TriangleAlert, Waypoints, Plus } from 'lucide-svelte';
let roles = $state<InternalRole[]>([]);
let modules = $state<InternalRoleModuleDefinition[]>([]);
let loading = $state(true);
let loadError = $state('');
async function load() {
loading = true;
loadError = '';
try {
const [roleList, moduleList] = await Promise.all([
api.accessRoles(),
api.accessRoleModules()
]);
roles = roleList;
modules = moduleList;
} catch (err: unknown) {
loadError = err instanceof Error ? err.message : 'Failed to load roles';
} finally {
loading = false;
}
}
onMount(load);
function emptyModulePermissions() {
return Object.fromEntries(modules.map((module) => [module.key, 'none'])) as Record<string, string>;
}
type FormMode = 'create' | 'edit';
let formOpen = $state(false);
let formMode = $state<FormMode>('create');
let formRoleId = $state<number | null>(null);
let formName = $state('');
let formDescription = $state('');
let formModulePermissions = $state<Record<string, string>>({});
let formProtected = $state(false);
let formSaving = $state(false);
let formError = $state('');
function openCreate() {
formMode = 'create';
formRoleId = null;
formName = '';
formDescription = '';
formModulePermissions = emptyModulePermissions();
formProtected = false;
formError = '';
formOpen = true;
}
function openEdit(role: InternalRole) {
formMode = 'edit';
formRoleId = role.id;
formName = role.name;
formDescription = role.description ?? '';
formModulePermissions = { ...emptyModulePermissions(), ...role.module_permissions };
formProtected = role.is_protected;
formError = '';
formOpen = true;
}
function closeForm() {
if (formSaving) return;
formOpen = false;
}
function setModuleLevel(moduleKey: string, level: string) {
formModulePermissions = { ...formModulePermissions, [moduleKey]: level };
}
function summary(role: InternalRole) {
return modules
.map((module) => {
const level = role.module_permissions[module.key];
return level && level !== 'none' ? `${module.label}: ${level}` : null;
})
.filter(Boolean)
.join(' • ');
}
async function saveForm() {
formError = '';
const name = formName.trim();
if (!name) {
formError = 'Role name is required';
return;
}
formSaving = true;
const tid = toast.loading(formMode === 'create' ? 'Creating role…' : 'Saving role…');
try {
const payload: InternalRoleCreateInput | InternalRoleUpdateInput = {
name,
description: formDescription.trim() || null,
module_permissions: formModulePermissions
};
if (formMode === 'create') {
await api.createAccessRole(payload as InternalRoleCreateInput);
} else if (formRoleId != null) {
await api.updateAccessRole(formRoleId, payload);
}
toast.dismiss(tid);
toast.success(formMode === 'create' ? 'Role created' : 'Role updated');
formOpen = false;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
const message = err instanceof Error ? err.message : 'An error occurred';
formError = message;
toast.error(message);
} finally {
formSaving = false;
}
}
let deleteRole = $state<InternalRole | null>(null);
let deleting = $state(false);
function openDelete(role: InternalRole) {
deleteRole = role;
}
function closeDelete() {
if (deleting) return;
deleteRole = null;
}
async function confirmDelete() {
if (!deleteRole) return;
deleting = true;
const tid = toast.loading('Deleting role…');
try {
await api.deleteAccessRole(deleteRole.id);
toast.dismiss(tid);
toast.success(`Deleted ${deleteRole.name}`);
deleteRole = null;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete role');
} finally {
deleting = false;
}
}
</script>
<div class="panel-section">
<header class="panel-header">
<div class="header-copy">
<h2>Roles</h2>
<p>Define which modules each role can open, edit, or manage.</p>
</div>
<button type="button" class="btn-primary" onclick={openCreate}>
<Plus size={16} strokeWidth={2.2} /> Add role
</button>
</header>
{#if loading}
<p class="state-msg">Loading roles…</p>
{:else if loadError}
<p class="state-msg error"><TriangleAlert size={15} strokeWidth={2.2} /> {loadError}</p>
{:else}
<div class="table-wrap">
<table class="roles-table">
<thead>
<tr>
<th>Role</th>
<th>Assigned users</th>
<th>Module access</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{#each roles as role (role.id)}
<tr>
<td>
<div class="role-cell">
<div class="role-title-row">
<strong>{role.name}</strong>
{#if role.is_protected}
<span class="protected-chip">
<ShieldCheck size={12} strokeWidth={2.4} /> Protected
</span>
{/if}
</div>
{#if role.description}
<p>{role.description}</p>
{/if}
</div>
</td>
<td>{role.user_count}</td>
<td class="summary-cell">{summary(role) || 'No module access'}</td>
<td class="actions-col">
<div class="row-actions">
<button type="button" class="icon-btn" title="Edit role" onclick={() => openEdit(role)}>
<Pencil size={15} strokeWidth={2.1} />
</button>
<button
type="button"
class="icon-btn danger"
title={role.is_protected
? 'Lean and admin roles cannot be deleted'
: role.user_count > 0
? 'Reassign users before deleting this role'
: 'Delete role'}
disabled={role.is_protected || role.user_count > 0}
onclick={() => openDelete(role)}
>
<Trash2 size={15} strokeWidth={2.1} />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if formOpen}
<div class="modal-backdrop" role="presentation" onclick={closeForm}>
<div
class="modal-card modal-card-wide"
role="dialog"
aria-modal="true"
aria-labelledby="role-form-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeForm(); }}
>
<div class="modal-top">
<div class="modal-icon"><Waypoints size={20} strokeWidth={2.2} /></div>
<div>
<h2 id="role-form-title" class="modal-title">{formMode === 'create' ? 'Add role' : 'Edit role'}</h2>
<p class="modal-text">Module access levels are translated into the underlying permissions automatically.</p>
</div>
</div>
<form class="modal-form" onsubmit={(event) => { event.preventDefault(); saveForm(); }}>
<div class="field-row">
<div class="field">
<label for="rf-name">Role name</label>
<input id="rf-name" type="text" bind:value={formName} disabled={formProtected && formMode === 'edit'} required />
</div>
<div class="field">
<label for="rf-description">Description</label>
<input id="rf-description" type="text" bind:value={formDescription} />
</div>
</div>
<div class="permissions-section">
<div class="permissions-head">
<div>
<h3>Module access</h3>
<p>Each row controls where this role can go and what it can do there.</p>
</div>
</div>
<div class="permissions-scroll">
<table class="permissions-table">
<thead>
<tr>
<th>Module</th>
<th>What it covers</th>
<th>Access level</th>
</tr>
</thead>
<tbody>
{#each modules as module (module.key)}
<tr>
<td class="module-name-cell">
<strong>{module.label}</strong>
</td>
<td class="module-description-cell">{module.description}</td>
<td class="module-level-cell">
<label class="matrix-select">
<span class="sr-only">Access level for {module.label}</span>
<select
value={formModulePermissions[module.key] ?? 'none'}
onchange={(event) => setModuleLevel(module.key, (event.currentTarget as HTMLSelectElement).value)}
>
{#each module.levels as level (level)}
<option value={level}>{level}</option>
{/each}
</select>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{#if formError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {formError}</p>
{/if}
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeForm} disabled={formSaving}>Cancel</button>
<button type="submit" class="btn-primary" disabled={formSaving}>
{formSaving ? 'Saving…' : formMode === 'create' ? 'Create role' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
{/if}
{#if deleteRole}
<div class="modal-backdrop" role="presentation" onclick={closeDelete}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-role-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeDelete(); }}
>
<div class="modal-icon danger"><Trash2 size={20} strokeWidth={2.2} /></div>
<h2 id="delete-role-title" class="modal-title">Delete role?</h2>
<p class="modal-text">
This removes <strong>{deleteRole.name}</strong>. Users must be reassigned first.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeDelete} disabled={deleting}>Cancel</button>
<button type="button" class="modal-confirm" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete role'}
</button>
</div>
</div>
</div>
{/if}
<style>
.panel-section {
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.header-copy h2,
.modal-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.header-copy p,
.modal-text {
margin: 0.3rem 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.btn-primary,
.modal-confirm,
.modal-cancel,
.icon-btn {
transition: opacity 140ms ease, border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 1.1rem;
background: var(--color-brand);
color: #fff;
border: none;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled,
.modal-confirm:disabled,
.modal-cancel:disabled,
.icon-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.state-msg {
padding: 1.5rem 1.75rem;
margin: 0;
color: var(--muted);
}
.state-msg.error,
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
color: #c53030;
}
.table-wrap {
overflow-x: auto;
padding: 0.5rem 1.75rem 1.75rem;
}
.roles-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.roles-table th,
.roles-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.roles-table th {
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.role-cell p,
.summary-cell {
margin: 0.25rem 0 0;
color: var(--muted);
line-height: 1.45;
}
.role-title-row {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.protected-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.08rem 0.42rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent);
color: var(--color-brand);
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
}
.actions-col {
text-align: right;
white-space: nowrap;
}
.row-actions {
display: inline-flex;
gap: 0.3rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel);
color: var(--muted);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--color-brand);
}
.icon-btn.danger:hover:not(:disabled) {
color: #c53030;
border-color: color-mix(in srgb, #e53e3e 45%, transparent);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(34rem, 100%);
display: grid;
gap: 0.8rem;
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card-wide {
width: min(54rem, 100%);
max-height: min(88vh, 60rem);
}
.modal-top {
display: flex;
gap: 0.85rem;
align-items: flex-start;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
color: var(--color-brand);
}
.modal-icon.danger {
background: #fdecee;
color: #b3261e;
}
.modal-form {
display: grid;
gap: 0.85rem;
min-height: 0;
}
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.field,
.matrix-select {
display: grid;
gap: 0.4rem;
}
.field label,
.matrix-select span {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.field input,
.matrix-select select {
width: 100%;
padding: 0.58rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
box-sizing: border-box;
}
.permissions-section {
display: grid;
gap: 0.75rem;
min-height: 0;
}
.permissions-head h3 {
margin: 0;
font-size: 0.94rem;
font-weight: 700;
color: var(--text);
}
.permissions-head p {
margin: 0.28rem 0 0;
font-size: 0.82rem;
color: var(--muted);
}
.permissions-scroll {
min-height: 0;
max-height: min(46vh, 30rem);
overflow: auto;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: var(--panel-soft);
}
.permissions-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.permissions-table th,
.permissions-table td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.permissions-table th {
position: sticky;
top: 0;
z-index: 1;
background: color-mix(in srgb, var(--panel) 92%, var(--panel-soft));
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.permissions-table tbody tr:last-child td {
border-bottom: none;
}
.module-name-cell {
width: 11rem;
}
.module-name-cell strong {
display: block;
color: var(--text);
}
.module-description-cell {
color: var(--muted);
font-size: 0.83rem;
line-height: 1.45;
}
.module-level-cell {
width: 11rem;
}
.module-level-cell .matrix-select {
gap: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.form-error {
margin: 0;
padding: 0.6rem 0.8rem;
background: color-mix(in srgb, #e53e3e 8%, transparent);
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
border-radius: 0.55rem;
font-size: 0.83rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.3rem;
}
.modal-cancel {
padding: 0.55rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
.modal-confirm {
padding: 0.55rem 1.1rem;
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
@media (max-width: 860px) {
.field-row {
grid-template-columns: 1fr;
}
.permissions-scroll {
max-height: min(44vh, 26rem);
}
.permissions-table,
.permissions-table thead,
.permissions-table tbody,
.permissions-table tr,
.permissions-table th,
.permissions-table td {
display: block;
}
.permissions-table thead {
display: none;
}
.permissions-table tbody {
display: grid;
}
.permissions-table tr {
display: grid;
gap: 0.55rem;
padding: 0.95rem 1rem;
border-bottom: 1px solid var(--line);
}
.permissions-table td {
width: auto;
padding: 0;
border: none;
}
.module-level-cell .matrix-select {
gap: 0.35rem;
}
.module-level-cell .matrix-select .sr-only {
position: static;
width: auto;
height: auto;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
}
}
@media (max-width: 720px) {
.panel-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,851 @@
<script lang="ts">
import { onMount } from 'svelte';
import { tooltip } from '$lib/actions/tooltip';
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import { toast } from '$lib/toast';
import type { InternalRoleOption, InternalUser } from '$lib/types';
import { UserPlus, Pencil, KeyRound, Trash2, ShieldCheck, TriangleAlert } from 'lucide-svelte';
let users = $state<InternalUser[]>([]);
let roles = $state<InternalRoleOption[]>([]);
let loading = $state(true);
let loadError = $state('');
const currentUserId = $derived($clientSession?.user_id ?? null);
async function load() {
loading = true;
loadError = '';
try {
const [userList, roleList] = await Promise.all([
api.accessUsers(),
api.accessAssignableRoles()
]);
users = userList;
roles = roleList;
} catch (err: unknown) {
loadError = err instanceof Error ? err.message : 'Failed to load users';
} finally {
loading = false;
}
}
onMount(load);
// ── Create / edit modal ───────────────────────────────────────
type FormMode = 'create' | 'edit';
let formOpen = $state(false);
let formMode = $state<FormMode>('create');
let formUserId = $state<number | null>(null);
let formName = $state('');
let formEmail = $state('');
let formRoleId = $state<number | null>(null);
let formActive = $state(true);
let formPassword = $state('');
let formSaving = $state(false);
let formError = $state('');
function openCreate() {
formMode = 'create';
formUserId = null;
formName = '';
formEmail = '';
formRoleId = roles[0]?.id ?? null;
formActive = true;
formPassword = '';
formError = '';
formOpen = true;
}
function openEdit(user: InternalUser) {
formMode = 'edit';
formUserId = user.id;
formName = user.name;
formEmail = user.email;
formRoleId = user.role_id;
formActive = user.is_active;
formPassword = '';
formError = '';
formOpen = true;
}
function closeForm() {
if (formSaving) return;
formOpen = false;
}
const editingSelf = $derived(formMode === 'edit' && formUserId === currentUserId);
async function saveForm() {
formError = '';
const name = formName.trim();
const email = formEmail.trim().toLowerCase();
if (!name) {
formError = 'Name is required';
return;
}
if (!email || !email.includes('@')) {
formError = 'A valid email is required';
return;
}
if (formMode === 'create' && formPassword && formPassword.length < 8) {
formError = 'Password must be at least 8 characters';
return;
}
formSaving = true;
const tid = toast.loading(formMode === 'create' ? 'Creating user…' : 'Saving user…');
try {
if (formMode === 'create') {
await api.createAccessUser({
name,
email,
role_id: formRoleId,
is_active: formActive,
password: formPassword ? formPassword : null
});
} else if (formUserId != null) {
await api.updateAccessUser(formUserId, {
name,
email,
role_id: formRoleId,
is_active: formActive
});
}
toast.dismiss(tid);
toast.success(formMode === 'create' ? 'User created' : 'User updated');
formOpen = false;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'An error occurred';
formError = msg;
toast.error(msg);
} finally {
formSaving = false;
}
}
// ── Password reset modal ──────────────────────────────────────
let pwOpen = $state(false);
let pwUser = $state<InternalUser | null>(null);
let pwNew = $state('');
let pwConfirm = $state('');
let pwSaving = $state(false);
let pwError = $state('');
function openPassword(user: InternalUser) {
pwUser = user;
pwNew = '';
pwConfirm = '';
pwError = '';
pwOpen = true;
}
function closePassword() {
if (pwSaving) return;
pwOpen = false;
}
async function savePassword() {
pwError = '';
if (pwNew.length < 8) {
pwError = 'Password must be at least 8 characters';
return;
}
if (pwNew !== pwConfirm) {
pwError = 'Passwords do not match';
return;
}
if (!pwUser) return;
pwSaving = true;
const tid = toast.loading('Updating password…');
try {
await api.setAccessUserPassword(pwUser.id, pwNew);
toast.dismiss(tid);
toast.success(`Password updated for ${pwUser.name}`);
pwOpen = false;
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'An error occurred';
pwError = msg;
toast.error(msg);
} finally {
pwSaving = false;
}
}
// ── Delete modal ──────────────────────────────────────────────
let deleteUser = $state<InternalUser | null>(null);
let deleting = $state(false);
function openDelete(user: InternalUser) {
deleteUser = user;
}
function closeDelete() {
if (deleting) return;
deleteUser = null;
}
async function confirmDelete() {
if (!deleteUser) return;
deleting = true;
const tid = toast.loading('Deleting user…');
try {
await api.deleteAccessUser(deleteUser.id);
toast.dismiss(tid);
toast.success(`Deleted ${deleteUser.name}`);
deleteUser = null;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
deleting = false;
}
}
// ── Quick active toggle ───────────────────────────────────────
async function toggleActive(user: InternalUser) {
if (user.id === currentUserId) {
toast.error('You cannot deactivate your own account');
return;
}
const next = !user.is_active;
const tid = toast.loading(next ? 'Enabling access…' : 'Disabling access…');
try {
const updated = await api.updateAccessUser(user.id, { is_active: next });
users = users.map((u) => (u.id === user.id ? updated : u));
toast.dismiss(tid);
toast.success(next ? `${user.name} can sign in` : `${user.name}'s access is off`);
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to update access');
}
}
</script>
<div class="panel-section">
<header class="panel-header">
<div class="header-copy">
<h2>Users</h2>
<p>Manage who can sign in to the workspace, their role, and their access.</p>
</div>
<button type="button" class="btn-primary" onclick={openCreate}>
<UserPlus size={16} strokeWidth={2.2} /> Add user
</button>
</header>
{#if loading}
<p class="state-msg">Loading users…</p>
{:else if loadError}
<p class="state-msg error"><TriangleAlert size={15} strokeWidth={2.2} /> {loadError}</p>
{:else}
<div class="table-wrap">
<table class="users-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Access</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr class:inactive={!user.is_active}>
<td>
<span class="user-name">{user.name}</span>
{#if user.id === currentUserId}<span class="you-chip">You</span>{/if}
{#if user.is_protected}
<span class="lean-chip" use:tooltip={'Lean owner, this account cannot be deleted'}>
<ShieldCheck size={12} strokeWidth={2.4} /> Lean
</span>
{/if}
</td>
<td class="email-cell">{user.email}</td>
<td>{user.role ?? '—'}</td>
<td>
<button
type="button"
class="status-toggle"
class:on={user.is_active}
disabled={user.id === currentUserId}
aria-label={user.id === currentUserId ? 'You cannot change your own access' : 'Toggle access'}
use:tooltip={user.id === currentUserId
? 'You cannot change your own access'
: user.is_active
? 'Turn sign-in access off'
: 'Turn sign-in access on'}
onclick={() => toggleActive(user)}
>
<span class="dot"></span>
{user.is_active ? 'Active' : 'Off'}
</button>
</td>
<td class="actions-col">
<div class="row-actions">
<button
type="button"
class="icon-btn"
aria-label="Edit user"
use:tooltip={'Edit user details'}
onclick={() => openEdit(user)}
>
<Pencil size={15} strokeWidth={2.1} />
</button>
<button
type="button"
class="icon-btn"
aria-label="Reset password"
use:tooltip={'Reset password'}
onclick={() => openPassword(user)}
>
<KeyRound size={15} strokeWidth={2.1} />
</button>
<button
type="button"
class="icon-btn danger"
aria-label="Delete user"
use:tooltip={user.is_protected
? 'Lean accounts cannot be deleted'
: user.id === currentUserId
? 'You cannot delete your own account'
: 'Delete user'}
disabled={user.is_protected || user.id === currentUserId}
onclick={() => openDelete(user)}
>
<Trash2 size={15} strokeWidth={2.1} />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Create / edit modal -->
{#if formOpen}
<div class="modal-backdrop" role="presentation" onclick={closeForm}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="user-form-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeForm(); }}
>
<h2 id="user-form-title" class="modal-title">{formMode === 'create' ? 'Add user' : 'Edit user'}</h2>
<form class="modal-form" onsubmit={(e) => { e.preventDefault(); saveForm(); }}>
<div class="field">
<label for="uf-name">Full name</label>
<input id="uf-name" type="text" bind:value={formName} autocomplete="off" required />
</div>
<div class="field">
<label for="uf-email">Email address</label>
<input id="uf-email" type="email" bind:value={formEmail} autocomplete="off" required />
</div>
<div class="field">
<label for="uf-role">Role</label>
<select id="uf-role" bind:value={formRoleId}>
<option value={null}>No role (no access)</option>
{#each roles as role (role.id)}
<option value={role.id}>{role.name}</option>
{/each}
</select>
</div>
{#if formMode === 'create'}
<div class="field">
<label for="uf-pass">Initial password <span class="optional">(optional)</span></label>
<input id="uf-pass" type="password" bind:value={formPassword} autocomplete="new-password" placeholder="Leave blank to use the shared password" />
</div>
{/if}
<label class="check-row" class:disabled={editingSelf}>
<input type="checkbox" bind:checked={formActive} disabled={editingSelf} />
<span>Access enabled {#if editingSelf}<em>(you cannot disable your own access)</em>{/if}</span>
</label>
{#if formError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {formError}</p>
{/if}
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeForm} disabled={formSaving}>Cancel</button>
<button type="submit" class="btn-primary" disabled={formSaving}>
{formSaving ? 'Saving…' : formMode === 'create' ? 'Create user' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Password reset modal -->
{#if pwOpen && pwUser}
<div class="modal-backdrop" role="presentation" onclick={closePassword}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="pw-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closePassword(); }}
>
<div class="modal-icon"><KeyRound size={20} strokeWidth={2.2} /></div>
<h2 id="pw-title" class="modal-title">Reset password</h2>
<p class="modal-text">Set a new password for <strong>{pwUser.name}</strong>. They can change it later in their own settings.</p>
<form class="modal-form" onsubmit={(e) => { e.preventDefault(); savePassword(); }}>
<div class="field">
<label for="pw-new">New password</label>
<input id="pw-new" type="password" bind:value={pwNew} autocomplete="new-password" required />
</div>
<div class="field">
<label for="pw-confirm">Confirm password</label>
<input id="pw-confirm" type="password" bind:value={pwConfirm} autocomplete="new-password" required />
</div>
{#if pwError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {pwError}</p>
{/if}
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closePassword} disabled={pwSaving}>Cancel</button>
<button type="submit" class="btn-primary" disabled={pwSaving}>
{pwSaving ? 'Updating…' : 'Set password'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete confirmation -->
{#if deleteUser}
<div class="modal-backdrop" role="presentation" onclick={closeDelete}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="del-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDelete(); }}
>
<div class="modal-icon danger"><Trash2 size={20} strokeWidth={2.2} /></div>
<h2 id="del-title" class="modal-title">Delete user?</h2>
<p class="modal-text">
This permanently removes <strong>{deleteUser.name}</strong> ({deleteUser.email}) and their
access. This cannot be undone.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeDelete} disabled={deleting}>Cancel</button>
<button type="button" class="modal-confirm" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete user'}
</button>
</div>
</div>
</div>
{/if}
<style>
.panel-section {
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.header-copy h2 {
margin: 0 0 0.3rem;
font-size: 1.1rem;
font-weight: 700;
}
.header-copy p {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
padding: 0.55rem 1.1rem;
background: var(--color-brand);
color: #fff;
border: none;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
transition: opacity 140ms ease;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.88;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.state-msg {
padding: 1.5rem 1.75rem;
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.state-msg.error {
display: flex;
align-items: center;
gap: 0.4rem;
color: #c53030;
}
/* ── Table ──────────────────────────────────────────────────── */
.table-wrap {
overflow-x: auto;
padding: 0.5rem 1.75rem 1.75rem;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.users-table th {
text-align: left;
padding: 0.7rem 0.75rem;
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
border-bottom: 1px solid var(--line);
}
.users-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--line);
color: var(--text);
vertical-align: middle;
}
.users-table tr.inactive td {
color: var(--muted);
}
.user-name {
font-weight: 600;
}
.email-cell {
color: var(--muted);
}
.you-chip,
.lean-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: 0.4rem;
padding: 0.08rem 0.42rem;
border-radius: 0.5rem;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
vertical-align: middle;
}
.you-chip {
background: var(--panel-soft);
border: 1px solid var(--line);
color: var(--muted);
}
.lean-chip {
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent);
color: var(--color-brand);
}
.actions-col {
text-align: right;
white-space: nowrap;
}
.row-actions {
display: inline-flex;
gap: 0.3rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel);
color: var(--muted);
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
}
.icon-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--color-brand);
}
.icon-btn.danger:hover:not(:disabled) {
color: #c53030;
border-color: color-mix(in srgb, #e53e3e 45%, transparent);
}
.icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status-toggle {
display: inline-flex;
align-items: center;
gap: 0.42rem;
padding: 0.32rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: border-color 140ms ease, color 140ms ease;
}
.status-toggle .dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--muted);
}
.status-toggle.on {
color: var(--color-brand);
border-color: color-mix(in srgb, var(--color-brand) 35%, transparent);
}
.status-toggle.on .dot {
background: var(--color-brand);
}
.status-toggle:disabled {
cursor: not-allowed;
opacity: 0.7;
}
/* ── Modal ──────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(30rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
color: var(--color-brand);
}
.modal-icon.danger {
background: #fdecee;
color: #b3261e;
}
.modal-title {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.modal-text {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
color: var(--muted);
}
.modal-form {
display: grid;
gap: 0.85rem;
margin-top: 0.3rem;
}
.field {
display: grid;
gap: 0.4rem;
}
.field label {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.field .optional {
font-weight: 400;
color: var(--muted);
}
.field input,
.field select {
width: 100%;
padding: 0.58rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
box-sizing: border-box;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
.check-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text);
cursor: pointer;
}
.check-row.disabled {
color: var(--muted);
cursor: not-allowed;
}
.check-row em {
color: var(--muted);
font-style: normal;
}
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0.6rem 0.8rem;
background: color-mix(in srgb, #e53e3e 8%, transparent);
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
border-radius: 0.55rem;
color: #c53030;
font-size: 0.83rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.4rem;
}
.modal-cancel,
.modal-confirm {
padding: 0.55rem 1.1rem;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms ease, opacity 150ms ease;
}
.modal-cancel {
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
}
.modal-cancel:hover:not(:disabled) {
color: var(--text);
}
.modal-confirm {
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
}
.modal-confirm:hover:not(:disabled) {
background: #95201a;
}
.modal-confirm:disabled,
.modal-cancel:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (max-width: 720px) {
.panel-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+87
View File
@@ -419,6 +419,26 @@ export type EditorIngredientCreateInput = {
export type EditorIngredientUpdateInput = Partial<EditorIngredientCreateInput>; export type EditorIngredientUpdateInput = Partial<EditorIngredientCreateInput>;
export type EditorChangeFieldDelta = {
field: string;
label: string;
before: string | null;
after: string | null;
};
export type EditorChangeEvent = {
id: number;
entity_type: 'mix' | 'ingredient';
entity_id: number;
action: string;
actor_name: string;
actor_email: string;
actor_role: string | null;
summary: string;
changes: EditorChangeFieldDelta[];
created_at: string;
};
export type Scenario = { export type Scenario = {
id: number; id: number;
name: string; name: string;
@@ -592,6 +612,69 @@ export type LoginResponse = {
role_name?: string | null; role_name?: string | null;
}; };
// Internal Hunter Stock Feeds user (the access-control system), as returned by
// /api/access/users. Distinct from the B2B ordering ClientUser accounts.
export type InternalUser = {
id: number;
email: string;
name: string;
is_active: boolean;
role: string | null;
role_id: number | null;
// Lean owner accounts: editable but never deletable.
is_protected: boolean;
};
export type InternalRoleOption = {
id: number;
name: string;
description: string | null;
};
export type InternalRoleModuleDefinition = {
key: string;
label: string;
description: string;
levels: string[];
};
export type InternalRole = {
id: number;
name: string;
description: string | null;
permissions: string[];
module_permissions: Record<string, string>;
is_protected: boolean;
user_count: number;
};
export type InternalRoleCreateInput = {
name: string;
description?: string | null;
module_permissions: Record<string, string>;
};
export type InternalRoleUpdateInput = {
name?: string;
description?: string | null;
module_permissions?: Record<string, string>;
};
export type InternalUserCreateInput = {
email: string;
name: string;
role_id?: number | null;
is_active?: boolean;
password?: string | null;
};
export type InternalUserUpdateInput = {
name?: string;
email?: string;
role_id?: number | null;
is_active?: boolean;
};
export type RawMaterialCreateInput = { export type RawMaterialCreateInput = {
name: string; name: string;
supplier?: string | null; supplier?: string | null;
@@ -716,6 +799,10 @@ export type ThroughputImportResult = {
errors: string[]; errors: string[];
}; };
export type ThroughputDeleteAllResult = {
entries_deleted: number;
};
export type ThroughputEntryListParams = { export type ThroughputEntryListParams = {
date_from?: string; date_from?: string;
date_to?: string; date_to?: string;
+19 -1
View File
@@ -2,6 +2,7 @@
import { api } from '$lib/api'; import { api } from '$lib/api';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
import SortHeader from '$lib/table/SortHeader.svelte'; import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte'; import { TableController } from '$lib/table/table.svelte';
import type { import type {
@@ -11,7 +12,7 @@
EditorMixUpdateInput, EditorMixUpdateInput,
RawMaterial RawMaterial
} from '$lib/types'; } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte'; import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let { data } = $props(); let { data } = $props();
@@ -42,6 +43,9 @@
// rescales every row's kg from its %. // rescales every row's kg from its %.
let totalReference = $state(0); let totalReference = $state(0);
// The mix whose change history is open in the modal (null = closed).
let historyMix = $state<EditableRow | null>(null);
// Inline "create new mix" form state. // Inline "create new mix" form state.
let creatingMix = $state(false); let creatingMix = $state(false);
let newMixClient = $state(''); let newMixClient = $state('');
@@ -531,6 +535,10 @@
<FlaskConical size={16} strokeWidth={2.2} /> <FlaskConical size={16} strokeWidth={2.2} />
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'} {expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
</button> </button>
<button class="clear-button" type="button" onclick={() => (historyMix = row)} aria-label={`History for ${row.name}`}>
<History size={16} strokeWidth={2.2} />
History
</button>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}> <button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} /> <Save size={16} strokeWidth={2.4} />
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'} {savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'}
@@ -617,6 +625,16 @@
{/each} {/each}
</div> </div>
</section> </section>
{#if historyMix}
<ChangeHistoryModal
entityType="mix"
entityId={historyMix.id}
title={historyMix.name}
subtitle={historyMix.client_name}
onClose={() => (historyMix = null)}
/>
{/if}
</AppSecondaryRailLayout> </AppSecondaryRailLayout>
<style> <style>
+18 -1
View File
@@ -2,11 +2,12 @@
import { api } from '$lib/api'; import { api } from '$lib/api';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
import SortHeader from '$lib/table/SortHeader.svelte'; import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte'; import { TableController } from '$lib/table/table.svelte';
import { formatNumber } from '$lib/format'; import { formatNumber } from '$lib/format';
import type { EditorIngredientRow } from '$lib/types'; import type { EditorIngredientRow } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte'; import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let { data } = $props(); let { data } = $props();
@@ -37,6 +38,8 @@
let query = $state(''); let query = $state('');
let statusFilter = $state<'active' | 'archived' | 'all'>('active'); let statusFilter = $state<'active' | 'archived' | 'all'>('active');
let savingKey = $state<string | null>(null); let savingKey = $state<string | null>(null);
// The ingredient whose change history is open in the modal (null = closed).
let historyIngredient = $state<EditableIngredient | null>(null);
$effect(() => { $effect(() => {
if (rows.length === 0 && (data.ingredients as EditorIngredientRow[]).length > 0) { if (rows.length === 0 && (data.ingredients as EditorIngredientRow[]).length > 0) {
@@ -380,6 +383,10 @@
</div> </div>
<div class="row-actions"> <div class="row-actions">
<button class="clear-button" type="button" onclick={() => (historyIngredient = row)} aria-label={`History for ${row.name}`}>
<History size={16} strokeWidth={2.2} />
History
</button>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}> <button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} /> <Save size={16} strokeWidth={2.4} />
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save'} {savingKey === `row:${row.id}` ? 'Saving...' : 'Save'}
@@ -406,6 +413,16 @@
{/each} {/each}
</div> </div>
</section> </section>
{#if historyIngredient}
<ChangeHistoryModal
entityType="ingredient"
entityId={historyIngredient.id}
title={historyIngredient.name}
subtitle={historyIngredient.unit_of_measure}
onClose={() => (historyIngredient = null)}
/>
{/if}
</AppSecondaryRailLayout> </AppSecondaryRailLayout>
<style> <style>
+318 -3
View File
@@ -2,18 +2,28 @@
import { api } from '$lib/api'; import { api } from '$lib/api';
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte'; import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { clientSession } from '$lib/session'; import RoleManagementPanel from '$lib/components/settings/RoleManagementPanel.svelte';
import UserManagementPanel from '$lib/components/settings/UserManagementPanel.svelte';
import { clientSession, hasPermission } from '$lib/session';
import { canEditThroughput } from '$lib/workspace-access'; import { canEditThroughput } from '$lib/workspace-access';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import type { ThroughputImportResult } from '$lib/types'; import type { ThroughputImportResult } from '$lib/types';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte'; import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert, Trash2, Users } from 'lucide-svelte';
type Section = 'profile' | 'security' | 'import'; type Section = 'profile' | 'security' | 'import' | 'users' | 'roles';
let activeSection = $state<Section>('profile'); let activeSection = $state<Section>('profile');
// Only operators who can edit throughput see (and can use) the import tool. // Only operators who can edit throughput see (and can use) the import tool.
const canImportThroughput = $derived(canEditThroughput($clientSession)); const canImportThroughput = $derived(canEditThroughput($clientSession));
// Lean owners and admins (manage_users permission) get the User management
// section. Visibility is a convenience — every endpoint enforces the
// permission itself.
const canManageUsers = $derived(hasPermission($clientSession, 'manage_users'));
const canManageRoles = $derived(
$clientSession?.role === 'internal' && ['lean', 'admin'].includes($clientSession?.role_name?.toLowerCase() ?? '')
);
let name = $state($clientSession?.name ?? ''); let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? ''); let email = $state($clientSession?.email ?? '');
@@ -117,6 +127,47 @@
} }
} }
// ── Delete all throughput entries ─────────────────────────────
// A destructive maintenance action — clearing a bad import in one go rather
// than deleting runs one by one. Guarded behind a typed confirmation.
let showDeleteAll = $state(false);
let deleteAllConfirmText = $state('');
let deletingAll = $state(false);
let deleteAllResult = $state<number | null>(null);
function openDeleteAll() {
deleteAllConfirmText = '';
deleteAllResult = null;
showDeleteAll = true;
}
function cancelDeleteAll() {
if (deletingAll) return;
showDeleteAll = false;
deleteAllConfirmText = '';
}
async function confirmDeleteAll() {
if (deleteAllConfirmText.trim().toUpperCase() !== 'DELETE') return;
deletingAll = true;
const tid = toast.loading('Deleting all entries…');
try {
const result = await api.deleteAllThroughputEntries();
deleteAllResult = result.entries_deleted;
showDeleteAll = false;
deleteAllConfirmText = '';
toast.dismiss(tid);
toast.success(
`Deleted ${result.entries_deleted} ${result.entries_deleted === 1 ? 'entry' : 'entries'}`
);
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete entries');
} finally {
deletingAll = false;
}
}
// Build a small sample CSV in the browser so operators have a working header // Build a small sample CSV in the browser so operators have a working header
// row to copy from. The backend matches these headers case-insensitively. // row to copy from. The backend matches these headers case-insensitively.
function downloadTemplate() { function downloadTemplate() {
@@ -171,6 +222,8 @@
{ id: 'profile', label: 'Profile', icon: CircleUserRound }, { id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole }, { id: 'security', label: 'Security', icon: LockKeyhole },
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []), ...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
...(canManageUsers ? [{ id: 'users' as Section, label: 'Users', icon: Users }] : []),
...(canManageRoles ? [{ id: 'roles' as Section, label: 'Roles', icon: Users }] : []),
]); ]);
const railGroups = $derived([{ items: navItems }]); const railGroups = $derived([{ items: navItems }]);
@@ -339,11 +392,81 @@
</div> </div>
</div> </div>
</div> </div>
<div class="danger-zone">
<div class="danger-copy">
<h3><TriangleAlert size={16} strokeWidth={2.2} /> Delete all entries</h3>
<p>
Permanently remove every throughput entry. Products are kept, but all
packing runs are erased. This cannot be undone — use it to clear a bad
import before re-uploading.
</p>
{#if deleteAllResult !== null}
<p class="danger-result" role="status">
Deleted <strong>{deleteAllResult}</strong>
{deleteAllResult === 1 ? 'entry' : 'entries'}.
</p>
{/if}
</div>
<button type="button" class="btn-danger" onclick={openDeleteAll}>
<Trash2 size={15} strokeWidth={2.2} /> Delete all entries
</button>
</div>
</div> </div>
{:else if activeSection === 'users' && canManageUsers}
<UserManagementPanel />
{:else if activeSection === 'roles' && canManageRoles}
<RoleManagementPanel />
{/if} {/if}
</div> </div>
</AppSecondaryRailLayout> </AppSecondaryRailLayout>
{#if showDeleteAll}
<div class="modal-backdrop" role="presentation" onclick={cancelDeleteAll}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-all-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') cancelDeleteAll(); }}
>
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
<h2 id="delete-all-title" class="modal-title">Delete all throughput entries?</h2>
<p class="modal-text">
This permanently removes <strong>every</strong> throughput entry for your
workspace. Products are kept, but the packing-run history cannot be recovered.
</p>
<label class="modal-confirm-field">
<span>Type <strong>DELETE</strong> to confirm</span>
<input
type="text"
bind:value={deleteAllConfirmText}
autocomplete="off"
spellcheck="false"
placeholder="DELETE"
/>
</label>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={cancelDeleteAll} disabled={deletingAll}>
Cancel
</button>
<button
type="button"
class="modal-confirm"
disabled={deletingAll || deleteAllConfirmText.trim().toUpperCase() !== 'DELETE'}
onclick={confirmDeleteAll}
>
{deletingAll ? 'Deleting…' : 'Delete all entries'}
</button>
</div>
</div>
</div>
{/if}
<style> <style>
.settings-panel { .settings-panel {
display: flex; display: flex;
@@ -622,6 +745,193 @@
overflow-y: auto; overflow-y: auto;
} }
/* ── Danger zone ────────────────────────────────────────────── */
.danger-zone {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.25rem;
margin: 0 1.75rem 1.75rem;
padding: 1.1rem 1.25rem;
border: 1px solid color-mix(in srgb, #e53e3e 30%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, #e53e3e 5%, transparent);
}
.danger-copy {
min-width: 0;
}
.danger-copy h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.35rem;
font-size: 0.92rem;
font-weight: 700;
color: #c53030;
}
.danger-copy p {
margin: 0;
font-size: 0.83rem;
line-height: 1.5;
color: var(--muted);
max-width: 34rem;
}
.danger-result {
margin-top: 0.5rem !important;
color: var(--text) !important;
}
.btn-danger {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.58rem 1.1rem;
background: #b3261e;
color: #fff;
border: 1px solid #b3261e;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
transition: background-color 140ms ease;
}
.btn-danger:hover {
background: #95201a;
}
/* ── Confirmation modal ─────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(28rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: #fdecee;
color: #b3261e;
}
.modal-title {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.modal-text {
margin: 0;
font-size: 0.92rem;
line-height: 1.5;
color: var(--muted);
}
.modal-confirm-field {
display: grid;
gap: 0.4rem;
margin-top: 0.2rem;
}
.modal-confirm-field span {
font-size: 0.82rem;
color: var(--muted);
}
.modal-confirm-field input {
width: 100%;
padding: 0.55rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
letter-spacing: 0.06em;
box-sizing: border-box;
}
.modal-confirm-field input:focus {
outline: none;
border-color: #b3261e;
box-shadow: 0 0 0 3px color-mix(in srgb, #b3261e 18%, transparent);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.modal-cancel,
.modal-confirm {
padding: 0.55rem 1.1rem;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.modal-cancel {
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
}
.modal-cancel:hover:not(:disabled) {
color: var(--text);
}
.modal-confirm {
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
}
.modal-confirm:hover:not(:disabled) {
background: #95201a;
}
.modal-confirm:disabled,
.modal-cancel:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* ── Responsive ─────────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -632,5 +942,10 @@
.import-body { .import-body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.danger-zone {
flex-direction: column;
align-items: flex-start;
}
} }
</style> </style>