Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions docs/waiter.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ On startup, CocktailBerry validates that the NFC reader is available and disable
Before your staff can use the machine, you need to register their NFC chips.
Open the Service Personnel management window from the options menu.
When a staff member scans their NFC chip, the scanned ID appears in the management view.
You can then assign a name and select which permissions that person should have (Maker, Ingredients, Recipes, Bottles).
Each permission controls whether the person can access that tab, and whether they can bypass the maker password for it.
You can then assign a name and select which permissions that person should have (Maker, Ingredients, Recipes, Bottles, Options).
Each tab permission controls whether the person can access that tab and bypass the maker password for it.
The Options permission controls whether the person can bypass the master password, see the [warning below](#password-bypass) for details.

## Login and Logout Flow

Expand All @@ -63,11 +64,13 @@ sequenceDiagram
Once logged in, the staff member can then prepare cocktails, and each preparation is logged to their name.
Logout happens either manually, automatically after a cocktail, or after a configured timeout.

### Difference in Appearance Between v1 and v2
## Difference in Appearance Between v1 and v2

Since we use distinct GUI technologies for v1 (Qt) and v2 (Web), there are some differences in how the logged-in staff member is displayed and how logout works.
See the according section for your version below.

### Logged-in Staff Display and Logout

=== "v1"

On the maker view, there is no dedicated staff indicator or logout button.
Expand All @@ -78,19 +81,25 @@ See the according section for your version below.
The currently logged-in staff member is displayed as an inline badge on the maker screen, showing their name.
Clicking the badge reveals a dedicated logout button, allowing the staff member to log out directly.

### Password Bypass
## Password Bypass

If a staff member has the appropriate permissions, they can bypass the maker password for the tabs they have access to.
See some specifics on implementation and differences between v1 and v2 below.
If a staff member has the appropriate tab permissions, they bypass the maker password for the tabs they have access to.
Staff members with the Options permission additionally bypass the master password, granting access to the options menu and other master-password-protected actions like deleting recipes or ingredients.

=== "v1"
!!! danger "Grant with Care"
The **Options** permission effectively grants full administrative access to CocktailBerry.
A staff member with this permission can bypass the master password, which means they can:

Staff members need to be logged in (e.g. by scanning their NFC chip) before they can access a protected tab without entering the maker password.
Scanning the NFC chip while already be prompted for the password will not remove the password, so be sure to log in before navigating to the protected tab.
- Change any configuration setting
- Delete and manage recipes and ingredients
- Create, edit, and delete other service personnel (including granting themselves more permissions)
- Access system functions like reboot, shutdown, backups, and updates

=== "v2"
Only grant this permission to fully trusted and authorized personnel.

Staff members can also scan the NFC when prompted for the maker password, which will bypass the password if they have the required permissions.
In both v1 and v2, a staff member can scan their NFC chip while being prompted for a password.
If they have the required permission, the password dialog is automatically accepted.
Additionally, if a staff member is already logged in before navigating to a protected area, the password prompt is skipped entirely.

## Statistics

Expand Down
9 changes: 9 additions & 0 deletions src/api/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,18 @@ def _waiter_has_tab_permission(tab: Tab) -> bool:
return permission_by_tab.get(tab, False)


def _waiter_has_options_permission() -> bool:
waiter = shared.current_waiter
if waiter is None:
return False
return waiter.permissions.options


def master_protected_dependency(master_password: str | None = Security(master_password_header)) -> None:
if cfg.UI_MASTERPASSWORD == 0:
return
if cfg.waiter_mode_active and _waiter_has_options_permission():
return
password = _parse_password(master_password)
if password != cfg.UI_MASTERPASSWORD:
raise HTTPException(status_code=403, detail="Invalid Master Password")
Expand Down
7 changes: 6 additions & 1 deletion src/api/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Annotated, TypeVar
from typing import TYPE_CHECKING, Annotated, Literal, TypeVar

from annotated_types import Len
from pydantic import BaseModel, Field
Expand Down Expand Up @@ -156,11 +156,15 @@ class SumupReaderCreate(BaseModel):
pairing_code: str


PermissionKey = Literal["maker", "ingredients", "recipes", "bottles", "options"]


class WaiterPermissions(BaseModel):
maker: bool = False
ingredients: bool = False
recipes: bool = False
bottles: bool = False
options: bool = False


class WaiterResponse(BaseModel):
Expand All @@ -178,6 +182,7 @@ def from_db(cls, waiter: DbWaiter) -> WaiterResponse:
ingredients=waiter.privilege_ingredients,
recipes=waiter.privilege_recipes,
bottles=waiter.privilege_bottles,
options=waiter.privilege_options,
),
)

Expand Down
6 changes: 5 additions & 1 deletion src/dialog_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from src.utils import get_platform_data

if TYPE_CHECKING:
from src.api.models import PermissionKey
from src.ui.setup_custom_dialog import CustomDialog
from src.ui_elements import (
Ui_addingredient,
Expand Down Expand Up @@ -253,19 +254,22 @@ def password_prompt(
self,
right_password: int,
header_type: Literal["master", "maker"] = "master",
permission_key: PermissionKey | None = None,
) -> bool:
"""Open a password prompt, return if successful entered password.

Option to also use other than master password
This is useful for example for the UI_MAKER_PASSWORD, or if needed for more things in the future.
If permission_key is provided and waiter mode is active, an NFC scan with
sufficient permissions will auto-accept the dialog.
"""
from src.ui.setup_password_dialog import PasswordDialog

# if password is empty, return true
# Empty means zero in this case, since the config is an int
if right_password == 0:
return True
return PasswordDialog(right_password, header_type).exec()
return PasswordDialog(right_password, header_type, permission_key=permission_key).exec()

def __output_language_dialog(
self,
Expand Down
3 changes: 3 additions & 0 deletions src/language.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,9 @@ ui:
permission_bottles:
en: 'Bottles'
de: 'Flaschen'
permission_options:
en: 'Options'
de: 'Optionen'
create_waiter:
en: 'Create'
de: 'Erstellen'
Expand Down
2 changes: 1 addition & 1 deletion src/tabs/ingredients.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def delete_ingredient(w: MainScreen) -> None:
return
if not DP_CONTROLLER.ask_to_delete_x(selected_ingredient):
return
if not DP_CONTROLLER.password_prompt(cfg.UI_MASTERPASSWORD):
if not DP_CONTROLLER.password_prompt(cfg.UI_MASTERPASSWORD, permission_key="options"):
return

ingredient = DB_COMMANDER.get_ingredient(selected_ingredient)
Expand Down
11 changes: 11 additions & 0 deletions src/tabs/qt_tab_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from enum import IntEnum


class TabIndex(IntEnum):
"""Enum for the qt tab indices."""

SEARCH = 0
MAKER = 1
INGREDIENTS = 2
RECIPES = 3
BOTTLES = 4
2 changes: 1 addition & 1 deletion src/tabs/recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ def delete_recipe(w: MainScreen) -> None:
return
if not DP_CONTROLLER.ask_to_delete_x(recipe_name):
return
if not DP_CONTROLLER.password_prompt(cfg.UI_MASTERPASSWORD):
if not DP_CONTROLLER.password_prompt(cfg.UI_MASTERPASSWORD, permission_key="options"):
return

DB_COMMANDER.delete_recipe(recipe_name)
Expand Down
31 changes: 17 additions & 14 deletions src/ui/setup_mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@
# pylint: disable=unnecessary-lambda
import os
import platform
from typing import Any
from typing import TYPE_CHECKING, Any

from PyQt6.QtCore import QEvent, QEventLoop, QObject
from PyQt6.QtGui import QIntValidator, QMouseEvent
from PyQt6.QtWidgets import QLineEdit, QMainWindow
from zmq import IntEnum

from src import FUTURE_PYTHON_VERSION
from src.config.config_manager import CONFIG as cfg
Expand All @@ -32,6 +31,7 @@
is_python_deprecated,
)
from src.tabs import bottles, ingredients, recipes
from src.tabs.qt_tab_index import TabIndex
from src.ui.cocktail_view import CocktailView
from src.ui.icons import BUTTON_SIZE, IconSetter
from src.ui.setup_available_window import AvailableWindow
Expand All @@ -49,16 +49,8 @@
from src.ui_elements import Ui_MainWindow
from src.updater import UpdateInfo, Updater


class TabIndex(IntEnum):
"""Enum for the tab indices."""

SEARCH = 0
MAKER = 1
INGREDIENTS = 2
RECIPES = 3
BOTTLES = 4

if TYPE_CHECKING:
from src.api.models import PermissionKey

RESTRICTED_MODE_UNLOCK_SEQUENCE = [
TabIndex.INGREDIENTS,
Expand All @@ -72,6 +64,13 @@ class TabIndex(IntEnum):
TabIndex.BOTTLES,
]

_PERMISSION_BY_TAB_INDEX: dict[int, PermissionKey] = {
int(TabIndex.MAKER): "maker",
int(TabIndex.INGREDIENTS): "ingredients",
int(TabIndex.RECIPES): "recipes",
int(TabIndex.BOTTLES): "bottles",
}


class MainScreen(QMainWindow, Ui_MainWindow):
"""Creates the Mainscreen."""
Expand Down Expand Up @@ -349,7 +348,7 @@ def wait_for_selection(_: Any) -> None:

def open_option_window(self) -> None:
"""Open up the options."""
if not DP_CONTROLLER.password_prompt(cfg.UI_MASTERPASSWORD):
if not DP_CONTROLLER.password_prompt(cfg.UI_MASTERPASSWORD, permission_key="options"):
return
self.option_window = OptionWindow(self)

Expand Down Expand Up @@ -494,7 +493,11 @@ def handle_tab_bar_clicked(self, index: int) -> None:
if self._waiter_can_access_locked_tab(index):
self.previous_tab_index = index
return
if DP_CONTROLLER.password_prompt(cfg.UI_MAKER_PASSWORD, header_type="maker"):
if DP_CONTROLLER.password_prompt(
cfg.UI_MAKER_PASSWORD,
header_type="maker",
permission_key=_PERMISSION_BY_TAB_INDEX.get(index),
):
self.previous_tab_index = index
return
# Set back to the prev tab if password not right
Expand Down
61 changes: 58 additions & 3 deletions src/ui/setup_password_dialog.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
from typing import Literal
from __future__ import annotations

from PyQt6.QtCore import QEventLoop
from typing import TYPE_CHECKING, Literal

from PyQt6.QtCore import QEventLoop, pyqtSignal
from PyQt6.QtWidgets import QMainWindow

from src.config.config_manager import CONFIG as cfg
from src.config.config_manager import shared
from src.dialog_handler import UI_LANGUAGE
from src.display_controller import DP_CONTROLLER
from src.service.waiter_service import WaiterService
from src.ui_elements.passworddialog import Ui_PasswordDialog

if TYPE_CHECKING:
from src.api.models import PermissionKey


class PasswordDialog(QMainWindow, Ui_PasswordDialog):
"""Password dialog that blocks until closed and returns success/failure."""

def __init__(self, right_password: int, header_type: Literal["master", "maker"] = "master") -> None:
_waiter_accepted = pyqtSignal()

def __init__(
self,
right_password: int,
header_type: Literal["master", "maker"] = "master",
permission_key: PermissionKey | None = None,
) -> None:
super().__init__()
self.setupUi(self)
self.right_password = right_password
self._result = False
self._loop: QEventLoop | None = None
self._permission_key = permission_key
self._waiter_callback_name: str | None = None

DP_CONTROLLER.initialize_window_object(self)

Expand All @@ -31,8 +48,46 @@ def __init__(self, right_password: int, header_type: Literal["master", "maker"]

UI_LANGUAGE.adjust_password_window(self, header_type)

# Bridge signal for thread-safe waiter NFC callback
self._waiter_accepted.connect(lambda: self._finish(True))
self._register_waiter_callback()

def _register_waiter_callback(self) -> None:
"""Register a WaiterService callback to auto-accept on NFC scan with sufficient permissions."""
if self._permission_key is None or not cfg.waiter_mode_active:
return
self._waiter_callback_name = "password_dialog"
WaiterService().add_callback(self._waiter_callback_name, self._on_waiter_scan)

def _unregister_waiter_callback(self) -> None:
"""Remove the WaiterService callback if registered."""
if self._waiter_callback_name is None:
return
WaiterService().remove_callback(self._waiter_callback_name)
self._waiter_callback_name = None

def _on_waiter_scan(self) -> None:
"""Check waiter permissions on NFC scan and emit accept signal if sufficient."""
if shared.current_waiter is None or self._permission_key is None:
return

# Explicitly map, in case attributes get renamed
permissions = shared.current_waiter.permissions
permission_mapping: dict[str | None, bool] = {
"maker": permissions.maker,
"ingredients": permissions.ingredients,
"recipes": permissions.recipes,
"bottles": permissions.bottles,
"options": permissions.options,
None: False,
}

if permission_mapping.get(self._permission_key, False):
self._waiter_accepted.emit()

def _finish(self, result: bool) -> None:
"""Store result, exit event loop, close window."""
self._unregister_waiter_callback()
self._result = result
if self._loop is not None:
self._loop.quit()
Expand Down
3 changes: 2 additions & 1 deletion src/ui/setup_waiter_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
class WaiterWindow(QMainWindow, Ui_WaiterWindow):
"""Creates the waiter window Widget."""

_PERMISSION_KEYS = ("maker", "ingredients", "recipes", "bottles")
_PERMISSION_KEYS = ("maker", "ingredients", "recipes", "bottles", "options")

def __init__(self, mainscreen: MainScreen) -> None:
"""Init. Connect all the buttons and set window policy."""
Expand Down Expand Up @@ -230,6 +230,7 @@ def _start_edit_waiter(self, nfc_id: str) -> None:
self._edit_permission_boxes["ingredients"].setChecked(waiter.privilege_ingredients)
self._edit_permission_boxes["recipes"].setChecked(waiter.privilege_recipes)
self._edit_permission_boxes["bottles"].setChecked(waiter.privilege_bottles)
self._edit_permission_boxes["options"].setChecked(waiter.privilege_options)
self._refresh_waiters_list()

def _save_waiter(self) -> None:
Expand Down
10 changes: 9 additions & 1 deletion web_client/src/components/common/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,17 @@ export const MasterPasswordProtected: React.FC<MasterPasswordProtectedProps> = (
const { config } = useConfig();
const { masterAuthenticated, setMasterAuthenticated, setMasterPassword } = useAuth();
const hasPassword = config.UI_MASTERPASSWORD;
const { waiterState, isLoading: isWaiterLoading } = useWaiter();
const shouldCheckWaiter = Boolean(config.WAITER_MODE && hasPassword && !masterAuthenticated);
const waiterCanBypass = shouldCheckWaiter && Boolean(waiterState?.waiter?.permissions?.options);

if (shouldCheckWaiter && isWaiterLoading && !waiterState) {
return null;
}

return (
<ProtectedRoute
isProtected={hasPassword && !masterAuthenticated}
isProtected={hasPassword && !masterAuthenticated && !waiterCanBypass}
setAuthenticated={(password: number) => {
setMasterAuthenticated(true);
setMasterPassword(password);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const registeredWaiter: CurrentWaiterState = {
waiter: {
nfc_id: 'ABC123',
name: 'Alice',
permissions: { maker: true, ingredients: false, recipes: false, bottles: false },
permissions: { maker: true, ingredients: false, recipes: false, bottles: false, options: false, },
},
};

Expand Down
Loading
Loading