diff --git a/docs/waiter.md b/docs/waiter.md index e828a544..d19a92e3 100644 --- a/docs/waiter.md +++ b/docs/waiter.md @@ -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 @@ -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. @@ -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 diff --git a/src/api/middleware.py b/src/api/middleware.py index 405a76e1..dfdd3661 100644 --- a/src/api/middleware.py +++ b/src/api/middleware.py @@ -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") diff --git a/src/api/models.py b/src/api/models.py index 3d04807b..f4f60abb 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -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 @@ -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): @@ -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, ), ) diff --git a/src/dialog_handler.py b/src/dialog_handler.py index 76f5de7d..6133bcf8 100644 --- a/src/dialog_handler.py +++ b/src/dialog_handler.py @@ -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, @@ -253,11 +254,14 @@ 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 @@ -265,7 +269,7 @@ def password_prompt( # 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, diff --git a/src/language.yaml b/src/language.yaml index 281cb874..484ec54a 100644 --- a/src/language.yaml +++ b/src/language.yaml @@ -1036,6 +1036,9 @@ ui: permission_bottles: en: 'Bottles' de: 'Flaschen' + permission_options: + en: 'Options' + de: 'Optionen' create_waiter: en: 'Create' de: 'Erstellen' diff --git a/src/tabs/ingredients.py b/src/tabs/ingredients.py index 56c2bcd1..515c4a1f 100644 --- a/src/tabs/ingredients.py +++ b/src/tabs/ingredients.py @@ -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) diff --git a/src/tabs/qt_tab_index.py b/src/tabs/qt_tab_index.py new file mode 100644 index 00000000..46226534 --- /dev/null +++ b/src/tabs/qt_tab_index.py @@ -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 diff --git a/src/tabs/recipes.py b/src/tabs/recipes.py index 95579164..8b8767fc 100644 --- a/src/tabs/recipes.py +++ b/src/tabs/recipes.py @@ -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) diff --git a/src/ui/setup_mainwindow.py b/src/ui/setup_mainwindow.py index e5dec11e..6980da69 100644 --- a/src/ui/setup_mainwindow.py +++ b/src/ui/setup_mainwindow.py @@ -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 @@ -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 @@ -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, @@ -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.""" @@ -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) @@ -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 diff --git a/src/ui/setup_password_dialog.py b/src/ui/setup_password_dialog.py index 9b4d9545..3f6eff41 100644 --- a/src/ui/setup_password_dialog.py +++ b/src/ui/setup_password_dialog.py @@ -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) @@ -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() diff --git a/src/ui/setup_waiter_window.py b/src/ui/setup_waiter_window.py index 573c2a8b..5e38c369 100644 --- a/src/ui/setup_waiter_window.py +++ b/src/ui/setup_waiter_window.py @@ -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.""" @@ -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: diff --git a/web_client/src/components/common/ProtectedRoute.tsx b/web_client/src/components/common/ProtectedRoute.tsx index eb7265ca..fc502315 100644 --- a/web_client/src/components/common/ProtectedRoute.tsx +++ b/web_client/src/components/common/ProtectedRoute.tsx @@ -83,9 +83,17 @@ export const MasterPasswordProtected: React.FC = ( 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 ( { setMasterAuthenticated(true); setMasterPassword(password); diff --git a/web_client/src/components/common/WaiterDisplay/index.stories.ts b/web_client/src/components/common/WaiterDisplay/index.stories.ts index 5592ef58..07bcb811 100644 --- a/web_client/src/components/common/WaiterDisplay/index.stories.ts +++ b/web_client/src/components/common/WaiterDisplay/index.stories.ts @@ -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, }, }, }; diff --git a/web_client/src/components/options/WaiterWindow.tsx b/web_client/src/components/options/WaiterWindow.tsx index ede677d3..e59949dd 100644 --- a/web_client/src/components/options/WaiterWindow.tsx +++ b/web_client/src/components/options/WaiterWindow.tsx @@ -47,8 +47,9 @@ const DEFAULT_PERMISSIONS: WaiterPermissions = { ingredients: false, recipes: false, bottles: false, + options: false, }; -const PERMISSION_KEYS = ['maker', 'ingredients', 'recipes', 'bottles'] as const; +const PERMISSION_KEYS = ['maker', 'ingredients', 'recipes', 'bottles', 'options'] as const; const ManagementTab: React.FC = () => { const { config } = useConfig(); diff --git a/web_client/src/locales/de/translation.json b/web_client/src/locales/de/translation.json index 107476cf..be91cb64 100644 --- a/web_client/src/locales/de/translation.json +++ b/web_client/src/locales/de/translation.json @@ -259,7 +259,8 @@ "maker": "Maker", "ingredients": "Zutaten", "recipes": "Rezepte", - "bottles": "Flaschen" + "bottles": "Flaschen", + "options": "Optionen" }, "logout": "Abmelden" } diff --git a/web_client/src/locales/en/translation.json b/web_client/src/locales/en/translation.json index ec5323a3..4438f394 100644 --- a/web_client/src/locales/en/translation.json +++ b/web_client/src/locales/en/translation.json @@ -259,7 +259,8 @@ "maker": "Maker", "ingredients": "Ingredients", "recipes": "Recipes", - "bottles": "Bottles" + "bottles": "Bottles", + "options": "Options" }, "logout": "Logout" } diff --git a/web_client/src/types/models.ts b/web_client/src/types/models.ts index 4a3e6712..6eee0351 100644 --- a/web_client/src/types/models.ts +++ b/web_client/src/types/models.ts @@ -351,6 +351,7 @@ export interface WaiterPermissions { ingredients: boolean; recipes: boolean; bottles: boolean; + options: boolean; } export interface Waiter {