v0.1.27
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:
+547
-19
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) == []
|
||||||
@@ -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 = (
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user