From 6276e9e3ac163390cb6aa557cd216d6fdb94fe42 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 5 May 2026 20:18:57 +0200 Subject: [PATCH] feat(loyalty): pair POS terminal devices with one-time setup QR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the backend half of the Android tablet rollout. Merchants can pair tablets to specific stores from /merchants/loyalty/devices (or admins can pair on behalf from the merchant detail page). Each pairing issues a long-lived JWT shown ONCE in the response with a server-rendered QR PNG containing {api_url, store_code, auth_token} — the tablet scans it on first boot and persists the three fields. The store API (/api/v1/store/loyalty/*) now accepts these device JWTs alongside user JWTs. Revoking a device row immediately rejects its token (401 TERMINAL_DEVICE_REVOKED). Tokens expire after 1 year; re-pair to renew. - Migration loyalty_010 + TerminalDevice model - create_device_token / verify_device_token JWT helpers - 5 endpoints x 2 portals (merchant + admin on-behalf) - Bearer-auth wiring in app/api/deps.py - Pages, shared list partial with one-time pairing-QR modal, Alpine.js factories - Locale strings (en authoritative; fr/de/lb seeded with EN copy for translation) - 6 integration tests covering pair, list, revoke, idempotency, cross-merchant rejection, store-API auth via device JWT Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/deps.py | 71 +++++ app/modules/loyalty/definition.py | 8 + app/modules/loyalty/exceptions.py | 40 +++ app/modules/loyalty/locales/de.json | 48 +++- app/modules/loyalty/locales/en.json | 48 +++- app/modules/loyalty/locales/fr.json | 48 +++- app/modules/loyalty/locales/lb.json | 48 +++- .../loyalty_010_add_terminal_devices.py | 123 +++++++++ app/modules/loyalty/models/__init__.py | 5 + app/modules/loyalty/models/terminal_device.py | 127 +++++++++ app/modules/loyalty/routes/api/admin.py | 140 +++++++++- app/modules/loyalty/routes/api/merchant.py | 127 ++++++++- app/modules/loyalty/routes/pages/admin.py | 25 ++ app/modules/loyalty/routes/pages/merchant.py | 25 ++ app/modules/loyalty/schemas/__init__.py | 16 ++ .../loyalty/schemas/terminal_device.py | 72 +++++ app/modules/loyalty/services/__init__.py | 6 + .../services/terminal_device_service.py | 255 ++++++++++++++++++ .../admin/js/loyalty-merchant-devices.js | 23 ++ .../static/merchant/js/loyalty-devices.js | 16 ++ .../static/shared/js/loyalty-devices-list.js | 205 ++++++++++++++ .../loyalty/admin/merchant-devices.html | 27 ++ .../templates/loyalty/merchant/devices.html | 36 +++ .../loyalty/shared/devices-list.html | 192 +++++++++++++ .../integration/test_terminal_devices.py | 186 +++++++++++++ app/modules/tenancy/schemas/auth.py | 9 + middleware/auth.py | 51 ++++ requirements.txt | 1 + 28 files changed, 1971 insertions(+), 7 deletions(-) create mode 100644 app/modules/loyalty/migrations/versions/loyalty_010_add_terminal_devices.py create mode 100644 app/modules/loyalty/models/terminal_device.py create mode 100644 app/modules/loyalty/schemas/terminal_device.py create mode 100644 app/modules/loyalty/services/terminal_device_service.py create mode 100644 app/modules/loyalty/static/admin/js/loyalty-merchant-devices.js create mode 100644 app/modules/loyalty/static/merchant/js/loyalty-devices.js create mode 100644 app/modules/loyalty/static/shared/js/loyalty-devices-list.js create mode 100644 app/modules/loyalty/templates/loyalty/admin/merchant-devices.html create mode 100644 app/modules/loyalty/templates/loyalty/merchant/devices.html create mode 100644 app/modules/loyalty/templates/loyalty/shared/devices-list.html create mode 100644 app/modules/loyalty/tests/integration/test_terminal_devices.py diff --git a/app/api/deps.py b/app/api/deps.py index 49c3c09b..011e2a47 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -750,7 +750,68 @@ def get_current_store_from_cookie_or_header( return UserContext.from_user(user) +def _try_authenticate_terminal_device( + token: str, db: Session, request: Request | None +) -> UserContext | None: + """Validate a paired-terminal device JWT and return a synthetic store context. + + Returns ``None`` for any token that is not a device token (caller falls + through to the user-token path). Raises if the token *is* a device token + but the underlying device is unknown, revoked, or expired. + + The principal of record is the user who paired the device (``created_by_id``). + Audit logs see that user's id; ``UserContext.terminal_device_id`` carries + the device id so callers that care can distinguish. + """ + from app.modules.loyalty.exceptions import ( + TerminalDeviceExpiredException, + TerminalDeviceNotFoundException, + TerminalDeviceRevokedException, + ) + from app.modules.loyalty.services import terminal_device_service + from middleware.auth import AuthManager + + auth = AuthManager() + try: + payload = auth.verify_device_token(token) + except InvalidTokenException as e: + # Not a device token — fall through to the user-token path. + if "Not a device token" in str(e): + return None + raise + + jti = payload["jti"] + device = terminal_device_service.get_device_by_jti(db, jti) + if not device: + raise TerminalDeviceNotFoundException(jti) + if device.is_revoked: + raise TerminalDeviceRevokedException(device.id) + if device.is_expired: + raise TerminalDeviceExpiredException(device.id) + if not device.created_by_id: + raise TerminalDeviceRevokedException(device.id) + + user = ( + db.query(UserModel).filter(UserModel.id == device.created_by_id).first() + ) + if not user or not user.is_active: + raise TerminalDeviceRevokedException(device.id) + + client_ip = ( + request.client.host if request is not None and request.client else None + ) + terminal_device_service.touch_last_seen(db, device, ip=client_ip) + + user.token_store_id = device.store_id + user.token_store_code = payload.get("store_code") + user.token_store_role = "store_member" + user.terminal_device_id = device.id + + return UserContext.from_user(user) + + def get_current_store_api( + request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db), ) -> UserContext: @@ -762,6 +823,10 @@ def get_current_store_api( 1. Token contains store context (token_store_id) 2. User still has access to the store specified in the token + Also accepts paired terminal-device tokens (``device_setup`` claim): the + device row is the source of truth for revocation, and the principal of + record is the user who paired it. + Args: credentials: Bearer token from Authorization header db: Database session @@ -776,6 +841,12 @@ def get_current_store_api( if not credentials: raise InvalidTokenException("Authorization header required for API calls") + device_ctx = _try_authenticate_terminal_device( + credentials.credentials, db, request + ) + if device_ctx is not None: + return device_ctx + user = _validate_user_token(credentials.credentials, db) # Block admins from store API diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index 776007b8..4cd6f832 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -149,6 +149,7 @@ loyalty_module = ModuleDefinition( "analytics", # Merchant loyalty analytics "transactions", # Transaction feed "pins", # Staff PINs + "devices", # Terminal devices (POS tablets) "settings", # Settings (read-only) ], }, @@ -278,6 +279,13 @@ loyalty_module = ModuleDefinition( route="/merchants/loyalty/pins", order=30, ), + MenuItemDefinition( + id="devices", + label_key="loyalty.menu.terminal_devices", + icon="device-tablet", + route="/merchants/loyalty/devices", + order=32, + ), MenuItemDefinition( id="settings", label_key="loyalty.menu.settings", diff --git a/app/modules/loyalty/exceptions.py b/app/modules/loyalty/exceptions.py index 772fb630..a40a35cd 100644 --- a/app/modules/loyalty/exceptions.py +++ b/app/modules/loyalty/exceptions.py @@ -331,6 +331,42 @@ class OrderReferenceRequiredException(LoyaltyException): ) +# ============================================================================= +# Terminal Device Exceptions +# ============================================================================= + + +class TerminalDeviceNotFoundException(ResourceNotFoundException): + """Raised when a terminal device is not found.""" + + def __init__(self, identifier: str): + super().__init__("TerminalDevice", identifier) + + +class TerminalDeviceRevokedException(LoyaltyException): + """Raised when a request presents a revoked terminal device token.""" + + def __init__(self, device_id: int): + super().__init__( + message="Terminal device has been revoked", + error_code="TERMINAL_DEVICE_REVOKED", + details={"device_id": device_id}, + ) + self.status_code = 401 + + +class TerminalDeviceExpiredException(LoyaltyException): + """Raised when a request presents an expired terminal device token.""" + + def __init__(self, device_id: int): + super().__init__( + message="Terminal device token has expired — re-pair the tablet", + error_code="TERMINAL_DEVICE_EXPIRED", + details={"device_id": device_id}, + ) + self.status_code = 401 + + # ============================================================================= # Validation Exceptions # ============================================================================= @@ -385,6 +421,10 @@ __all__ = [ "CustomerIdentifierRequiredException", # Order "OrderReferenceRequiredException", + # Terminal Device + "TerminalDeviceNotFoundException", + "TerminalDeviceRevokedException", + "TerminalDeviceExpiredException", # Validation "LoyaltyValidationException", ] diff --git a/app/modules/loyalty/locales/de.json b/app/modules/loyalty/locales/de.json index 2bfc798b..8786ccec 100644 --- a/app/modules/loyalty/locales/de.json +++ b/app/modules/loyalty/locales/de.json @@ -83,7 +83,8 @@ "settings": "Einstellungen", "wallet_debug": "Wallet Debug", "staff_pins": "Mitarbeiter-PINs", - "transactions": "Transaktionen" + "transactions": "Transaktionen", + "terminal_devices": "Terminal Devices" }, "permissions": { "view_programs": "Programme anzeigen", @@ -910,6 +911,51 @@ "pin_delete_error": "PIN konnte nicht gelöscht werden", "pin_unlock_error": "PIN konnte nicht entsperrt werden" }, + "terminal_devices": { + "title": "Terminal Devices", + "subtitle": "Pair POS tablets to each store. The setup QR is shown only once at pairing time.", + "admin_subtitle": "Pair POS tablets on behalf of this merchant.", + "loading": "Loading devices...", + "error_loading": "Failed to load devices", + "no_devices": "No devices paired yet", + "show_revoked": "Include revoked", + "pair_device": "Pair Tablet", + "pair_hint": "A long-lived setup QR is generated. The tablet scans it once and connects from then on.", + "pairing_qr": "Tablet Pairing QR", + "qr_warning_title": "Save this now —", + "qr_warning_body": "this QR is shown only this time. If you close it before pairing, you will need to revoke and pair again.", + "qr_done": "I've paired the tablet", + "show_payload": "Show raw payload", + "label_field": "Device label", + "label_placeholder": "e.g. Counter 1", + "store_field": "Store", + "select_store": "Select store...", + "revoke": "Revoke", + "revoke_title": "Revoke device?", + "confirm_revoke": "Revoke device '{label}'? Its tablet will stop working immediately.", + "delete_title": "Delete device?", + "confirm_delete": "Permanently delete device '{label}'? This cannot be undone.", + "col_label": "Label", + "col_store": "Store", + "col_status": "Status", + "col_last_seen": "Last seen", + "col_expires": "Expires", + "col_actions": "Actions", + "status_active": "Active", + "status_revoked": "Revoked", + "status_expired": "Expired", + "toasts": { + "paired": "Tablet paired — show the QR to the device", + "revoked": "Device revoked", + "deleted": "Device deleted" + }, + "errors": { + "store_required": "Please select a store", + "pair_failed": "Failed to pair device", + "revoke_failed": "Failed to revoke device", + "delete_failed": "Failed to delete device" + } + }, "errors": { "card_not_found": "Karte nicht gefunden" } diff --git a/app/modules/loyalty/locales/en.json b/app/modules/loyalty/locales/en.json index 26535633..b5361f72 100644 --- a/app/modules/loyalty/locales/en.json +++ b/app/modules/loyalty/locales/en.json @@ -93,7 +93,8 @@ "settings": "Settings", "wallet_debug": "Wallet Debug", "staff_pins": "Staff PINs", - "transactions": "Transactions" + "transactions": "Transactions", + "terminal_devices": "Terminal Devices" }, "onboarding": { "create_program": { @@ -928,6 +929,51 @@ "pin_delete_error": "Failed to delete PIN", "pin_unlock_error": "Failed to unlock PIN" }, + "terminal_devices": { + "title": "Terminal Devices", + "subtitle": "Pair POS tablets to each store. The setup QR is shown only once at pairing time.", + "admin_subtitle": "Pair POS tablets on behalf of this merchant.", + "loading": "Loading devices...", + "error_loading": "Failed to load devices", + "no_devices": "No devices paired yet", + "show_revoked": "Include revoked", + "pair_device": "Pair Tablet", + "pair_hint": "A long-lived setup QR is generated. The tablet scans it once and connects from then on.", + "pairing_qr": "Tablet Pairing QR", + "qr_warning_title": "Save this now —", + "qr_warning_body": "this QR is shown only this time. If you close it before pairing, you will need to revoke and pair again.", + "qr_done": "I've paired the tablet", + "show_payload": "Show raw payload", + "label_field": "Device label", + "label_placeholder": "e.g. Counter 1", + "store_field": "Store", + "select_store": "Select store...", + "revoke": "Revoke", + "revoke_title": "Revoke device?", + "confirm_revoke": "Revoke device '{label}'? Its tablet will stop working immediately.", + "delete_title": "Delete device?", + "confirm_delete": "Permanently delete device '{label}'? This cannot be undone.", + "col_label": "Label", + "col_store": "Store", + "col_status": "Status", + "col_last_seen": "Last seen", + "col_expires": "Expires", + "col_actions": "Actions", + "status_active": "Active", + "status_revoked": "Revoked", + "status_expired": "Expired", + "toasts": { + "paired": "Tablet paired — show the QR to the device", + "revoked": "Device revoked", + "deleted": "Device deleted" + }, + "errors": { + "store_required": "Please select a store", + "pair_failed": "Failed to pair device", + "revoke_failed": "Failed to revoke device", + "delete_failed": "Failed to delete device" + } + }, "errors": { "card_not_found": "Card not found" } diff --git a/app/modules/loyalty/locales/fr.json b/app/modules/loyalty/locales/fr.json index d142d819..e5551cf9 100644 --- a/app/modules/loyalty/locales/fr.json +++ b/app/modules/loyalty/locales/fr.json @@ -83,7 +83,8 @@ "settings": "Paramètres", "wallet_debug": "Wallet Debug", "staff_pins": "PINs du personnel", - "transactions": "Transactions" + "transactions": "Transactions", + "terminal_devices": "Terminal Devices" }, "permissions": { "view_programs": "Voir les programmes", @@ -910,6 +911,51 @@ "pin_delete_error": "Erreur lors de la suppression du PIN", "pin_unlock_error": "Erreur lors du déverrouillage du PIN" }, + "terminal_devices": { + "title": "Terminal Devices", + "subtitle": "Pair POS tablets to each store. The setup QR is shown only once at pairing time.", + "admin_subtitle": "Pair POS tablets on behalf of this merchant.", + "loading": "Loading devices...", + "error_loading": "Failed to load devices", + "no_devices": "No devices paired yet", + "show_revoked": "Include revoked", + "pair_device": "Pair Tablet", + "pair_hint": "A long-lived setup QR is generated. The tablet scans it once and connects from then on.", + "pairing_qr": "Tablet Pairing QR", + "qr_warning_title": "Save this now —", + "qr_warning_body": "this QR is shown only this time. If you close it before pairing, you will need to revoke and pair again.", + "qr_done": "I've paired the tablet", + "show_payload": "Show raw payload", + "label_field": "Device label", + "label_placeholder": "e.g. Counter 1", + "store_field": "Store", + "select_store": "Select store...", + "revoke": "Revoke", + "revoke_title": "Revoke device?", + "confirm_revoke": "Revoke device '{label}'? Its tablet will stop working immediately.", + "delete_title": "Delete device?", + "confirm_delete": "Permanently delete device '{label}'? This cannot be undone.", + "col_label": "Label", + "col_store": "Store", + "col_status": "Status", + "col_last_seen": "Last seen", + "col_expires": "Expires", + "col_actions": "Actions", + "status_active": "Active", + "status_revoked": "Revoked", + "status_expired": "Expired", + "toasts": { + "paired": "Tablet paired — show the QR to the device", + "revoked": "Device revoked", + "deleted": "Device deleted" + }, + "errors": { + "store_required": "Please select a store", + "pair_failed": "Failed to pair device", + "revoke_failed": "Failed to revoke device", + "delete_failed": "Failed to delete device" + } + }, "errors": { "card_not_found": "Carte non trouvée" } diff --git a/app/modules/loyalty/locales/lb.json b/app/modules/loyalty/locales/lb.json index 93d1b33b..b3c2f492 100644 --- a/app/modules/loyalty/locales/lb.json +++ b/app/modules/loyalty/locales/lb.json @@ -83,7 +83,8 @@ "settings": "Astellungen", "wallet_debug": "Wallet Debug", "staff_pins": "Mataarbechter-PINen", - "transactions": "Transaktionen" + "transactions": "Transaktionen", + "terminal_devices": "Terminal Devices" }, "permissions": { "view_programs": "Programmer kucken", @@ -910,6 +911,51 @@ "pin_delete_error": "PIN konnt net geläscht ginn", "pin_unlock_error": "PIN konnt net entspäert ginn" }, + "terminal_devices": { + "title": "Terminal Devices", + "subtitle": "Pair POS tablets to each store. The setup QR is shown only once at pairing time.", + "admin_subtitle": "Pair POS tablets on behalf of this merchant.", + "loading": "Loading devices...", + "error_loading": "Failed to load devices", + "no_devices": "No devices paired yet", + "show_revoked": "Include revoked", + "pair_device": "Pair Tablet", + "pair_hint": "A long-lived setup QR is generated. The tablet scans it once and connects from then on.", + "pairing_qr": "Tablet Pairing QR", + "qr_warning_title": "Save this now —", + "qr_warning_body": "this QR is shown only this time. If you close it before pairing, you will need to revoke and pair again.", + "qr_done": "I've paired the tablet", + "show_payload": "Show raw payload", + "label_field": "Device label", + "label_placeholder": "e.g. Counter 1", + "store_field": "Store", + "select_store": "Select store...", + "revoke": "Revoke", + "revoke_title": "Revoke device?", + "confirm_revoke": "Revoke device '{label}'? Its tablet will stop working immediately.", + "delete_title": "Delete device?", + "confirm_delete": "Permanently delete device '{label}'? This cannot be undone.", + "col_label": "Label", + "col_store": "Store", + "col_status": "Status", + "col_last_seen": "Last seen", + "col_expires": "Expires", + "col_actions": "Actions", + "status_active": "Active", + "status_revoked": "Revoked", + "status_expired": "Expired", + "toasts": { + "paired": "Tablet paired — show the QR to the device", + "revoked": "Device revoked", + "deleted": "Device deleted" + }, + "errors": { + "store_required": "Please select a store", + "pair_failed": "Failed to pair device", + "revoke_failed": "Failed to revoke device", + "delete_failed": "Failed to delete device" + } + }, "errors": { "card_not_found": "Kaart net fonnt" } diff --git a/app/modules/loyalty/migrations/versions/loyalty_010_add_terminal_devices.py b/app/modules/loyalty/migrations/versions/loyalty_010_add_terminal_devices.py new file mode 100644 index 00000000..d78b6a83 --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_010_add_terminal_devices.py @@ -0,0 +1,123 @@ +"""loyalty 010 - add terminal_devices table + +Pairs Android (or other) POS tablets to a single store. Stores only the +JWT id (`jti`); the token itself is shown once at pairing and never +persisted. Multiple devices per store are allowed. + +Revision ID: loyalty_010 +Revises: loyalty_009 +Create Date: 2026-05-04 +""" +import sqlalchemy as sa + +from alembic import op + +revision = "loyalty_010" +down_revision = "loyalty_009" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "loyalty_terminal_devices", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "merchant_id", + sa.Integer(), + sa.ForeignKey("merchants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "store_id", + sa.Integer(), + sa.ForeignKey("stores.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("label", sa.String(length=100), nullable=False), + sa.Column("jti", sa.String(length=36), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_seen_ip", sa.String(length=45), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "revoked_by_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("revoked_reason", sa.String(length=255), nullable=True), + sa.Column( + "created_by_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.CheckConstraint( + "expires_at > created_at", + name="ck_loyalty_terminal_devices_expiry_after_creation", + ), + ) + + op.create_index( + "ix_loyalty_terminal_devices_merchant_id", + "loyalty_terminal_devices", + ["merchant_id"], + ) + op.create_index( + "ix_loyalty_terminal_devices_store_id", + "loyalty_terminal_devices", + ["store_id"], + ) + op.create_index( + "ix_loyalty_terminal_devices_revoked_at", + "loyalty_terminal_devices", + ["revoked_at"], + ) + op.create_index( + "ix_loyalty_terminal_devices_jti", + "loyalty_terminal_devices", + ["jti"], + unique=True, + ) + op.create_index( + "idx_loyalty_terminal_devices_store_active", + "loyalty_terminal_devices", + ["store_id", "revoked_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "idx_loyalty_terminal_devices_store_active", + table_name="loyalty_terminal_devices", + ) + op.drop_index( + "ix_loyalty_terminal_devices_jti", + table_name="loyalty_terminal_devices", + ) + op.drop_index( + "ix_loyalty_terminal_devices_revoked_at", + table_name="loyalty_terminal_devices", + ) + op.drop_index( + "ix_loyalty_terminal_devices_store_id", + table_name="loyalty_terminal_devices", + ) + op.drop_index( + "ix_loyalty_terminal_devices_merchant_id", + table_name="loyalty_terminal_devices", + ) + op.drop_table("loyalty_terminal_devices") diff --git a/app/modules/loyalty/models/__init__.py b/app/modules/loyalty/models/__init__.py index e1d5ef21..9cb23822 100644 --- a/app/modules/loyalty/models/__init__.py +++ b/app/modules/loyalty/models/__init__.py @@ -49,6 +49,10 @@ from app.modules.loyalty.models.staff_pin import ( # Model StaffPin, ) +from app.modules.loyalty.models.terminal_device import ( + # Model + TerminalDevice, +) from app.modules.loyalty.models.transaction_category import ( # Model StoreTransactionCategory, @@ -67,4 +71,5 @@ __all__ = [ "AppleDeviceRegistration", "MerchantLoyaltySettings", "StoreTransactionCategory", + "TerminalDevice", ] diff --git a/app/modules/loyalty/models/terminal_device.py b/app/modules/loyalty/models/terminal_device.py new file mode 100644 index 00000000..dc67f2bc --- /dev/null +++ b/app/modules/loyalty/models/terminal_device.py @@ -0,0 +1,127 @@ +# app/modules/loyalty/models/terminal_device.py +""" +Terminal device model. + +A TerminalDevice represents a paired Android (or other) POS tablet bound +to a single store. Each row owns the unique `jti` that we encode in the +device's long-lived JWT — that's the only handle we keep on the issued +token, since the JWT itself is shown once at pairing time and then never +stored. Two states render a device's token unusable: explicit `revoked_at` +(merchant action, row stays for audit) or `expires_at < now` (1y default). + +Multiple devices per store are allowed — a store can have several cashiers +each with their own tablet — so there is no unique constraint across +(store_id), only an index for lookups. +""" + +from datetime import UTC, datetime + +from sqlalchemy import ( + CheckConstraint, + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class TerminalDevice(Base, TimestampMixin): + """A paired POS terminal device (typically an Android tablet).""" + + __tablename__ = "loyalty_terminal_devices" + + id = Column(Integer, primary_key=True, index=True) + + merchant_id = Column( + Integer, + ForeignKey("merchants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + store_id = Column( + Integer, + ForeignKey("stores.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + label = Column( + String(100), + nullable=False, + comment="Merchant-facing device name e.g. 'Counter 1'", + ) + + # The only handle we keep on the issued JWT. The token itself is shown + # once at creation and never stored. + jti = Column(String(36), nullable=False, unique=True, index=True) + + expires_at = Column(DateTime(timezone=True), nullable=False) + last_seen_at = Column(DateTime(timezone=True), nullable=True) + last_seen_ip = Column(String(45), nullable=True) + + revoked_at = Column(DateTime(timezone=True), nullable=True, index=True) + revoked_by_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + revoked_reason = Column(String(255), nullable=True) + + # The user who paired the device. Used as the principal of record when + # the device performs operations on the store API. + created_by_id = Column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + + merchant = relationship("Merchant", backref="terminal_devices") + store = relationship("Store", backref="terminal_devices") + revoked_by = relationship("User", foreign_keys=[revoked_by_id]) + created_by = relationship("User", foreign_keys=[created_by_id]) + + __table_args__ = ( + CheckConstraint( + "expires_at > created_at", + name="ck_loyalty_terminal_devices_expiry_after_creation", + ), + Index( + "idx_loyalty_terminal_devices_store_active", + "store_id", + "revoked_at", + ), + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + + @property + def is_expired(self) -> bool: + if not self.expires_at: + return False + expiry = self.expires_at + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=UTC) + return datetime.now(UTC) >= expiry + + @property + def status(self) -> str: + """One of: active, revoked, expired.""" + if self.is_revoked: + return "revoked" + if self.is_expired: + return "expired" + return "active" diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index 0248e593..e38ce7cb 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -35,10 +35,21 @@ from app.modules.loyalty.schemas import ( ProgramResponse, ProgramStatsResponse, ProgramUpdate, + TerminalDeviceCreate, + TerminalDeviceListResponse, + TerminalDevicePairingResponse, + TerminalDeviceResponse, + TerminalDeviceRevoke, + TerminalDeviceUpdate, TransactionListResponse, TransactionResponse, ) -from app.modules.loyalty.services import card_service, pin_service, program_service +from app.modules.loyalty.services import ( + card_service, + pin_service, + program_service, + terminal_device_service, +) from app.modules.loyalty.services.analytics_service import analytics_service from app.modules.tenancy.models import User # API-007 @@ -1035,3 +1046,130 @@ def restore_deleted_merchant_data( "cards_restored": cards_restored, "message": f"Restored {programs_restored} program(s) and {cards_restored} card(s)", } + + +# ============================================================================= +# Terminal Devices (admin acts on behalf of merchant) +# ============================================================================= + + +@router.get( + "/merchants/{merchant_id}/devices", + response_model=TerminalDeviceListResponse, +) +def admin_list_terminal_devices( + merchant_id: int = Path(..., gt=0), + store_id: int | None = Query(None, gt=0), + include_revoked: bool = Query(False), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """List paired POS terminal devices for a merchant.""" + devices = terminal_device_service.list_devices( + db, merchant_id, store_id=store_id, include_revoked=include_revoked + ) + return TerminalDeviceListResponse( + devices=[ + TerminalDeviceResponse(**terminal_device_service.build_response(d)) + for d in devices + ], + total=len(devices), + ) + + +@router.post( + "/merchants/{merchant_id}/devices", + response_model=TerminalDevicePairingResponse, + status_code=201, +) +def admin_pair_terminal_device( + data: TerminalDeviceCreate, + merchant_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Pair a terminal device on behalf of a merchant. Returns a one-time QR.""" + device, token, payload, qr_png = terminal_device_service.create_device( + db, + merchant_id=merchant_id, + data=data, + created_by_user_id=current_user.id, + ) + logger.info( + f"Admin {current_user.id} paired terminal device {device.id} " + f"for merchant {merchant_id}" + ) + return TerminalDevicePairingResponse( + **terminal_device_service.build_response(device), + setup_token=token, + setup_payload=payload, + qr_png_base64=qr_png, + ) + + +@router.patch( + "/merchants/{merchant_id}/devices/{device_id}", + response_model=TerminalDeviceResponse, +) +def admin_update_terminal_device( + data: TerminalDeviceUpdate, + merchant_id: int = Path(..., gt=0), + device_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Rename a paired device on behalf of a merchant.""" + device = terminal_device_service.require_device_for_merchant( + db, device_id, merchant_id + ) + device = terminal_device_service.update_device(db, device, data) + return TerminalDeviceResponse(**terminal_device_service.build_response(device)) + + +@router.post( + "/merchants/{merchant_id}/devices/{device_id}/revoke", + response_model=TerminalDeviceResponse, +) +def admin_revoke_terminal_device( + data: TerminalDeviceRevoke, + merchant_id: int = Path(..., gt=0), + device_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Revoke a paired device's token. The row stays for audit.""" + device = terminal_device_service.require_device_for_merchant( + db, device_id, merchant_id + ) + device = terminal_device_service.revoke_device( + db, + device, + revoked_by_user_id=current_user.id, + reason=data.reason, + ) + logger.info( + f"Admin {current_user.id} revoked terminal device {device_id} " + f"for merchant {merchant_id}" + ) + return TerminalDeviceResponse(**terminal_device_service.build_response(device)) + + +@router.delete( + "/merchants/{merchant_id}/devices/{device_id}", + status_code=204, +) +def admin_delete_terminal_device( + merchant_id: int = Path(..., gt=0), + device_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Permanently delete a paired device row.""" + device = terminal_device_service.require_device_for_merchant( + db, device_id, merchant_id + ) + terminal_device_service.delete_device(db, device) + logger.info( + f"Admin {current_user.id} deleted terminal device {device_id} " + f"for merchant {merchant_id}" + ) diff --git a/app/modules/loyalty/routes/api/merchant.py b/app/modules/loyalty/routes/api/merchant.py index 5684f569..e479bd2a 100644 --- a/app/modules/loyalty/routes/api/merchant.py +++ b/app/modules/loyalty/routes/api/merchant.py @@ -26,7 +26,7 @@ import logging from fastapi import APIRouter, Depends, Path, Query from sqlalchemy.orm import Session -from app.api.deps import get_merchant_for_current_user +from app.api.deps import get_current_merchant_api, get_merchant_for_current_user from app.core.database import get_db from app.modules.loyalty.exceptions import ( LoyaltyCardNotFoundException, @@ -45,12 +45,24 @@ from app.modules.loyalty.schemas import ( ProgramCreate, ProgramResponse, ProgramUpdate, + TerminalDeviceCreate, + TerminalDeviceListResponse, + TerminalDevicePairingResponse, + TerminalDeviceResponse, + TerminalDeviceRevoke, + TerminalDeviceUpdate, TransactionListResponse, TransactionResponse, ) from app.modules.loyalty.schemas.program import MerchantStatsResponse -from app.modules.loyalty.services import card_service, pin_service, program_service +from app.modules.loyalty.services import ( + card_service, + pin_service, + program_service, + terminal_device_service, +) from app.modules.tenancy.models import Merchant +from app.modules.tenancy.schemas.auth import UserContext logger = logging.getLogger(__name__) @@ -397,6 +409,117 @@ def get_settings( return MerchantSettingsResponse.model_validate(settings) +# ============================================================================= +# Terminal Devices +# ============================================================================= + + +@router.get("/devices", response_model=TerminalDeviceListResponse) +def list_terminal_devices( + store_id: int | None = Query(None, gt=0), + include_revoked: bool = Query(False), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """List paired POS terminal devices for the merchant.""" + devices = terminal_device_service.list_devices( + db, + merchant.id, + store_id=store_id, + include_revoked=include_revoked, + ) + return TerminalDeviceListResponse( + devices=[ + TerminalDeviceResponse(**terminal_device_service.build_response(d)) + for d in devices + ], + total=len(devices), + ) + + +@router.post( + "/devices", + response_model=TerminalDevicePairingResponse, + status_code=201, +) +def pair_terminal_device( + data: TerminalDeviceCreate, + merchant: Merchant = Depends(get_merchant_for_current_user), + current_user: UserContext = Depends(get_current_merchant_api), + db: Session = Depends(get_db), +): + """Pair a new POS tablet. Returns a one-time setup token + QR PNG. + + The setup token is shown only in this response — the server keeps only + the device's `jti` for revocation. If the merchant loses the QR they + must revoke the device and pair again. + """ + device, token, payload, qr_png = terminal_device_service.create_device( + db, + merchant_id=merchant.id, + data=data, + created_by_user_id=current_user.id, + ) + return TerminalDevicePairingResponse( + **terminal_device_service.build_response(device), + setup_token=token, + setup_payload=payload, + qr_png_base64=qr_png, + ) + + +@router.patch("/devices/{device_id}", response_model=TerminalDeviceResponse) +def update_terminal_device( + data: TerminalDeviceUpdate, + device_id: int = Path(..., gt=0), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Rename a paired device (label only — the token is unaffected).""" + device = terminal_device_service.require_device_for_merchant( + db, device_id, merchant.id + ) + device = terminal_device_service.update_device(db, device, data) + return TerminalDeviceResponse(**terminal_device_service.build_response(device)) + + +@router.post( + "/devices/{device_id}/revoke", + response_model=TerminalDeviceResponse, +) +def revoke_terminal_device( + data: TerminalDeviceRevoke, + device_id: int = Path(..., gt=0), + merchant: Merchant = Depends(get_merchant_for_current_user), + current_user: UserContext = Depends(get_current_merchant_api), + db: Session = Depends(get_db), +): + """Revoke a paired device's token. The row stays for audit.""" + device = terminal_device_service.require_device_for_merchant( + db, device_id, merchant.id + ) + device = terminal_device_service.revoke_device( + db, + device, + revoked_by_user_id=current_user.id, + reason=data.reason, + ) + return TerminalDeviceResponse(**terminal_device_service.build_response(device)) + + +@router.delete("/devices/{device_id}", status_code=204) +def delete_terminal_device( + device_id: int = Path(..., gt=0), + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Permanently delete a paired device row (revokes implicitly).""" + device = terminal_device_service.require_device_for_merchant( + db, device_id, merchant.id + ) + terminal_device_service.delete_device(db, device) + + # ============================================================================= # Locations # ============================================================================= diff --git a/app/modules/loyalty/routes/pages/admin.py b/app/modules/loyalty/routes/pages/admin.py index bc48b1f0..684ef63d 100644 --- a/app/modules/loyalty/routes/pages/admin.py +++ b/app/modules/loyalty/routes/pages/admin.py @@ -238,3 +238,28 @@ async def admin_loyalty_merchant_settings( "loyalty/admin/merchant-settings.html", get_admin_context(request, db, current_user, merchant_id=merchant_id), ) + + +@router.get( + "/merchants/{merchant_id}/devices", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_loyalty_merchant_devices( + request: Request, + merchant_id: int = Path(..., description="Merchant ID"), + current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """List + pair POS terminal devices on behalf of a merchant.""" + merchant_name = _get_merchant_name(db, merchant_id) + return templates.TemplateResponse( + "loyalty/admin/merchant-devices.html", + get_admin_context( + request, + db, + current_user, + merchant_id=merchant_id, + merchant_name=merchant_name, + ), + ) diff --git a/app/modules/loyalty/routes/pages/merchant.py b/app/modules/loyalty/routes/pages/merchant.py index f02493a7..ea874190 100644 --- a/app/modules/loyalty/routes/pages/merchant.py +++ b/app/modules/loyalty/routes/pages/merchant.py @@ -279,3 +279,28 @@ async def merchant_loyalty_settings( "loyalty/merchant/settings.html", context, ) + + +# ============================================================================ +# TERMINAL DEVICES (POS tablet pairing) +# ============================================================================ + + +@router.get("/devices", response_class=HTMLResponse, include_in_schema=False) +async def merchant_loyalty_devices( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + merchant: Merchant = Depends(get_merchant_for_current_user_page), + db: Session = Depends(get_db), +): + """List + pair POS terminal devices for the merchant.""" + context = _get_merchant_context( + request, + db, + current_user, + merchant_id=merchant.id, + ) + return templates.TemplateResponse( + "loyalty/merchant/devices.html", + context, + ) diff --git a/app/modules/loyalty/schemas/__init__.py b/app/modules/loyalty/schemas/__init__.py index 193a0192..4e6da940 100644 --- a/app/modules/loyalty/schemas/__init__.py +++ b/app/modules/loyalty/schemas/__init__.py @@ -83,6 +83,15 @@ from app.modules.loyalty.schemas.stamp import ( StampVoidRequest, StampVoidResponse, ) +from app.modules.loyalty.schemas.terminal_device import ( + # Terminal device pairing & management + TerminalDeviceCreate, + TerminalDeviceListResponse, + TerminalDevicePairingResponse, + TerminalDeviceResponse, + TerminalDeviceRevoke, + TerminalDeviceUpdate, +) __all__ = [ # Program @@ -129,4 +138,11 @@ __all__ = [ "PinDetailListResponse", "PinVerifyRequest", "PinVerifyResponse", + # Terminal device + "TerminalDeviceCreate", + "TerminalDeviceUpdate", + "TerminalDeviceRevoke", + "TerminalDeviceResponse", + "TerminalDevicePairingResponse", + "TerminalDeviceListResponse", ] diff --git a/app/modules/loyalty/schemas/terminal_device.py b/app/modules/loyalty/schemas/terminal_device.py new file mode 100644 index 00000000..043f68bb --- /dev/null +++ b/app/modules/loyalty/schemas/terminal_device.py @@ -0,0 +1,72 @@ +# app/modules/loyalty/schemas/terminal_device.py +"""Pydantic schemas for terminal device pairing & management.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class TerminalDeviceCreate(BaseModel): + """Pair a new terminal device. Issues a long-lived JWT shown once.""" + + store_id: int = Field(..., gt=0, description="Store the device is bound to") + label: str = Field( + ..., + min_length=1, + max_length=100, + description="Merchant-facing name e.g. 'Counter 1'", + ) + + +class TerminalDeviceUpdate(BaseModel): + """Update a paired device's label. Token is not affected.""" + + model_config = ConfigDict(from_attributes=True) + + label: str | None = Field(None, min_length=1, max_length=100) + + +class TerminalDeviceRevoke(BaseModel): + """Revoke a device's token, optionally with a reason for the audit log.""" + + reason: str | None = Field(None, max_length=255) + + +class TerminalDeviceResponse(BaseModel): + """Read view of a paired device. Never includes the JWT.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + merchant_id: int + store_id: int + store_name: str | None = None + label: str + status: str # active | revoked | expired + expires_at: datetime + last_seen_at: datetime | None = None + last_seen_ip: str | None = None + revoked_at: datetime | None = None + revoked_by_id: int | None = None + revoked_reason: str | None = None + created_by_id: int | None = None + created_at: datetime + + +class TerminalDevicePairingResponse(TerminalDeviceResponse): + """Returned by POST only. Carries the one-time setup token + QR PNG.""" + + setup_token: str = Field(..., description="The signed JWT — shown once") + setup_payload: dict = Field( + ..., + description="Decoded {api_url, store_code, auth_token} payload encoded in the QR", + ) + qr_png_base64: str = Field( + ..., + description="Base64-encoded PNG of the setup QR (data:image/png;base64,...)", + ) + + +class TerminalDeviceListResponse(BaseModel): + devices: list[TerminalDeviceResponse] + total: int diff --git a/app/modules/loyalty/services/__init__.py b/app/modules/loyalty/services/__init__.py index e3980f5f..3c32482f 100644 --- a/app/modules/loyalty/services/__init__.py +++ b/app/modules/loyalty/services/__init__.py @@ -34,6 +34,10 @@ from app.modules.loyalty.services.stamp_service import ( StampService, stamp_service, ) +from app.modules.loyalty.services.terminal_device_service import ( + TerminalDeviceService, + terminal_device_service, +) from app.modules.loyalty.services.wallet_service import ( WalletService, wallet_service, @@ -56,4 +60,6 @@ __all__ = [ "google_wallet_service", "AppleWalletService", "apple_wallet_service", + "TerminalDeviceService", + "terminal_device_service", ] diff --git a/app/modules/loyalty/services/terminal_device_service.py b/app/modules/loyalty/services/terminal_device_service.py new file mode 100644 index 00000000..e18c397e --- /dev/null +++ b/app/modules/loyalty/services/terminal_device_service.py @@ -0,0 +1,255 @@ +# app/modules/loyalty/services/terminal_device_service.py +"""Terminal device service. + +Pairs Android (or other) POS tablets to a single store. Issues a long-lived +JWT at pair time — the token itself is shown once and never persisted; only +the `jti` lives on the device row, which remains the source of truth for +revocation. + +Two failure modes render a token unusable: +- Explicit `revoked_at` (merchant action, row stays for audit) +- `expires_at < now` (1y default, no auto-renewal — re-pair the tablet) +""" + +import base64 +import io +import json +import logging +import uuid +from datetime import UTC, datetime, timedelta + +import qrcode +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.modules.loyalty.exceptions import TerminalDeviceNotFoundException +from app.modules.loyalty.models import TerminalDevice +from app.modules.loyalty.schemas.terminal_device import ( + TerminalDeviceCreate, + TerminalDeviceUpdate, +) +from middleware.auth import AuthManager + +logger = logging.getLogger(__name__) + + +DEFAULT_TOKEN_LIFETIME = timedelta(days=365) + + +class TerminalDeviceService: + """Service for pairing and managing terminal devices.""" + + def __init__(self): + self._auth = AuthManager() + + # ========================================================================= + # Reads + # ========================================================================= + + def get_device(self, db: Session, device_id: int) -> TerminalDevice | None: + return ( + db.query(TerminalDevice) + .filter(TerminalDevice.id == device_id) + .first() + ) + + def require_device(self, db: Session, device_id: int) -> TerminalDevice: + device = self.get_device(db, device_id) + if not device: + raise TerminalDeviceNotFoundException(str(device_id)) + return device + + def require_device_for_merchant( + self, db: Session, device_id: int, merchant_id: int + ) -> TerminalDevice: + """Fetch a device but enforce it belongs to the given merchant.""" + device = self.require_device(db, device_id) + if device.merchant_id != merchant_id: + raise TerminalDeviceNotFoundException(str(device_id)) + return device + + def get_device_by_jti(self, db: Session, jti: str) -> TerminalDevice | None: + return db.query(TerminalDevice).filter(TerminalDevice.jti == jti).first() + + def list_devices( + self, + db: Session, + merchant_id: int, + *, + store_id: int | None = None, + include_revoked: bool = False, + ) -> list[TerminalDevice]: + query = db.query(TerminalDevice).filter( + TerminalDevice.merchant_id == merchant_id + ) + if store_id is not None: + query = query.filter(TerminalDevice.store_id == store_id) + if not include_revoked: + query = query.filter(TerminalDevice.revoked_at.is_(None)) + return query.order_by( + TerminalDevice.store_id, TerminalDevice.created_at.desc() + ).all() + + # ========================================================================= + # Writes + # ========================================================================= + + def create_device( + self, + db: Session, + *, + merchant_id: int, + data: TerminalDeviceCreate, + created_by_user_id: int | None, + ) -> tuple[TerminalDevice, str, dict, str]: + """Pair a new device and issue its setup token. + + Returns ``(device, setup_token, setup_payload, qr_png_base64)``. + The caller must NOT persist ``setup_token`` — only the device row's + ``jti`` is kept. + """ + from app.modules.tenancy.models import Store + + store = ( + db.query(Store) + .filter(Store.id == data.store_id, Store.merchant_id == merchant_id) + .first() + ) + if not store: + raise TerminalDeviceNotFoundException(f"store:{data.store_id}") + + jti = str(uuid.uuid4()) + expires_at = datetime.now(UTC) + DEFAULT_TOKEN_LIFETIME + + device = TerminalDevice( + merchant_id=merchant_id, + store_id=store.id, + label=data.label, + jti=jti, + expires_at=expires_at, + created_by_id=created_by_user_id, + ) + db.add(device) + db.commit() + db.refresh(device) + + token = self._auth.create_device_token( + device_id=device.id, + jti=jti, + merchant_id=merchant_id, + store_id=store.id, + store_code=store.store_code, + expires_at=expires_at, + ) + + payload = { + "api_url": settings.app_base_url, + "store_code": store.store_code, + "auth_token": token, + } + qr_png_base64 = self._render_qr_png_base64(json.dumps(payload)) + + logger.info( + f"Paired terminal device {device.id} ('{device.label}') for " + f"merchant {merchant_id}, store {store.id} ({store.store_code})" + ) + return device, token, payload, qr_png_base64 + + def update_device( + self, + db: Session, + device: TerminalDevice, + data: TerminalDeviceUpdate, + ) -> TerminalDevice: + if data.label is not None: + device.label = data.label + db.commit() + db.refresh(device) + return device + + def revoke_device( + self, + db: Session, + device: TerminalDevice, + *, + revoked_by_user_id: int | None, + reason: str | None = None, + ) -> TerminalDevice: + if device.revoked_at is not None: + return device + device.revoked_at = datetime.now(UTC) + device.revoked_by_id = revoked_by_user_id + device.revoked_reason = reason + db.commit() + db.refresh(device) + logger.info( + f"Revoked terminal device {device.id} by user {revoked_by_user_id}" + ) + return device + + def delete_device(self, db: Session, device: TerminalDevice) -> None: + device_id = device.id + db.delete(device) + db.commit() + logger.info(f"Deleted terminal device {device_id}") + + def touch_last_seen( + self, + db: Session, + device: TerminalDevice, + ip: str | None = None, + ) -> None: + """Bump last_seen_at/ip on each authenticated request. Best-effort.""" + device.last_seen_at = datetime.now(UTC) + if ip is not None: + device.last_seen_ip = ip + try: + db.commit() + except Exception: # noqa: EXC003 - best-effort heartbeat, must not break the request + db.rollback() + logger.warning( + f"Failed to update last_seen for device {device.id}", exc_info=True + ) + + # ========================================================================= + # Helpers + # ========================================================================= + + @staticmethod + def _render_qr_png_base64(data: str) -> str: + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=8, + border=2, + ) + qr.add_data(data) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buffer = io.BytesIO() + img.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + @staticmethod + def build_response(device: TerminalDevice) -> dict: + """Hydrate a response dict including derived status + store name.""" + return { + "id": device.id, + "merchant_id": device.merchant_id, + "store_id": device.store_id, + "store_name": device.store.name if device.store else None, + "label": device.label, + "status": device.status, + "expires_at": device.expires_at, + "last_seen_at": device.last_seen_at, + "last_seen_ip": device.last_seen_ip, + "revoked_at": device.revoked_at, + "revoked_by_id": device.revoked_by_id, + "revoked_reason": device.revoked_reason, + "created_by_id": device.created_by_id, + "created_at": device.created_at, + } + + +terminal_device_service = TerminalDeviceService() diff --git a/app/modules/loyalty/static/admin/js/loyalty-merchant-devices.js b/app/modules/loyalty/static/admin/js/loyalty-merchant-devices.js new file mode 100644 index 00000000..e06d8970 --- /dev/null +++ b/app/modules/loyalty/static/admin/js/loyalty-merchant-devices.js @@ -0,0 +1,23 @@ +// app/modules/loyalty/static/admin/js/loyalty-merchant-devices.js +const adminMerchantDevicesLog = window.LogConfig.loggers.adminMerchantDevices || window.LogConfig.createLogger('adminMerchantDevices'); + +function adminMerchantDevices() { + const merchantId = document.body.dataset.merchantId + || (window.location.pathname.match(/\/merchants\/(\d+)/) || [])[1]; + if (!merchantId) { + adminMerchantDevicesLog.error('Could not determine merchant_id from URL'); + } + return loyaltyDevicesList({ + apiPrefix: '/admin/loyalty/merchants/' + merchantId, + // Admin reuses the merchant locations endpoint for the store dropdown. + locationsApiPrefix: '/admin/loyalty/merchants/' + merchantId, + showStoreFilter: true, + showCrud: true, + currentPage: 'admin-merchant-devices', + }); +} + +if (!window.LogConfig.loggers.adminMerchantDevices) { + window.LogConfig.loggers.adminMerchantDevices = window.LogConfig.createLogger('adminMerchantDevices'); +} +adminMerchantDevicesLog.info('Admin merchant devices module loaded'); diff --git a/app/modules/loyalty/static/merchant/js/loyalty-devices.js b/app/modules/loyalty/static/merchant/js/loyalty-devices.js new file mode 100644 index 00000000..1ae45362 --- /dev/null +++ b/app/modules/loyalty/static/merchant/js/loyalty-devices.js @@ -0,0 +1,16 @@ +// app/modules/loyalty/static/merchant/js/loyalty-devices.js +const merchantDevicesLog = window.LogConfig.loggers.merchantDevices || window.LogConfig.createLogger('merchantDevices'); + +function merchantLoyaltyDevices() { + return loyaltyDevicesList({ + apiPrefix: '/merchants/loyalty', + showStoreFilter: true, + showCrud: true, + currentPage: 'devices', + }); +} + +if (!window.LogConfig.loggers.merchantDevices) { + window.LogConfig.loggers.merchantDevices = window.LogConfig.createLogger('merchantDevices'); +} +merchantDevicesLog.info('Merchant loyalty devices module loaded'); diff --git a/app/modules/loyalty/static/shared/js/loyalty-devices-list.js b/app/modules/loyalty/static/shared/js/loyalty-devices-list.js new file mode 100644 index 00000000..2c331ba0 --- /dev/null +++ b/app/modules/loyalty/static/shared/js/loyalty-devices-list.js @@ -0,0 +1,205 @@ +// app/modules/loyalty/static/shared/js/loyalty-devices-list.js +// Shared Alpine.js data factory for terminal device pairing pages. +// Used by both merchant and admin views. + +const loyaltyDevicesListLog = window.LogConfig.loggers.loyaltyDevicesList || window.LogConfig.createLogger('loyaltyDevicesList'); + +/** + * Factory that returns an Alpine.js data object for terminal device management. + * + * @param {Object} config + * @param {string} config.apiPrefix - API path prefix (devices live under `${apiPrefix}/devices`) + * @param {string} [config.locationsApiPrefix] - Where to load store locations (defaults to apiPrefix) + * @param {boolean} config.showStoreFilter - Whether to show the store dropdown filter + * @param {boolean} config.showCrud - Whether to show pair/revoke/delete UI + * @param {string} config.currentPage - Alpine currentPage identifier + */ +function loyaltyDevicesList(config) { + const guardKey = '_loyaltyDevicesList_' + config.currentPage + '_initialized'; + const locationsPrefix = config.locationsApiPrefix || config.apiPrefix; + + return { + ...data(), + currentPage: config.currentPage, + + devices: [], + locations: [], + + filters: { + store_id: '', + include_revoked: false, + }, + + loading: false, + error: null, + + // Pair modal + showPairModal: false, + pairing: false, + pairForm: { label: '', store_id: '' }, + + // QR reveal modal (one-time) + showQrModal: false, + pairingResult: null, + + // Revoke / delete confirms + showRevokeModal: false, + showDeleteModal: false, + targetDevice: null, + revokeConfirmMessage: '', + deleteConfirmMessage: '', + + _config: config, + + async init() { + loyaltyDevicesListLog.info('=== LOYALTY DEVICES LIST INITIALIZING ===', config.currentPage); + if (window[guardKey]) return; + window[guardKey] = true; + + const parentInit = data().init; + if (parentInit) await parentInit.call(this); + + await this.loadData(); + }, + + async loadData() { + this.loading = true; + this.error = null; + try { + const parallel = [this.loadDevices()]; + if (config.showStoreFilter) parallel.push(this.loadLocations()); + await Promise.all(parallel); + } catch (error) { + loyaltyDevicesListLog.error('Failed to load data:', error); + this.error = error.message; + } finally { + this.loading = false; + } + }, + + async loadDevices() { + try { + const params = new URLSearchParams(); + if (this.filters.store_id) params.append('store_id', this.filters.store_id); + if (this.filters.include_revoked) params.append('include_revoked', 'true'); + const qs = params.toString(); + const url = config.apiPrefix + '/devices' + (qs ? '?' + qs : ''); + const response = await apiClient.get(url); + if (response) this.devices = response.devices || []; + } catch (error) { + loyaltyDevicesListLog.error('Failed to load devices:', error); + throw error; + } + }, + + async loadLocations() { + try { + const response = await apiClient.get(locationsPrefix + '/locations'); + if (response) { + this.locations = Array.isArray(response) ? response : (response.locations || []); + } + } catch (error) { + loyaltyDevicesListLog.warn('Failed to load locations:', error.message); + } + }, + + applyFilter() { + this.loadDevices(); + }, + + formatDate(value) { + if (!value) return '-'; + try { + return new Date(value).toLocaleString(); + } catch (e) { + return value; + } + }, + + // ---- Pair flow ---- + + openPairModal() { + this.pairForm = { label: '', store_id: '' }; + this.showPairModal = true; + }, + + async pairDevice() { + this.pairing = true; + try { + const payload = { + label: this.pairForm.label, + store_id: parseInt(this.pairForm.store_id, 10), + }; + if (!payload.store_id) { + Utils.showToast(I18n.t('loyalty.terminal_devices.errors.store_required'), 'error'); + this.pairing = false; + return; + } + const response = await apiClient.post(config.apiPrefix + '/devices', payload); + this.pairingResult = response; + this.showPairModal = false; + this.showQrModal = true; + await this.loadDevices(); + } catch (error) { + loyaltyDevicesListLog.error('Failed to pair device:', error); + Utils.showToast(error.message || I18n.t('loyalty.terminal_devices.errors.pair_failed'), 'error'); + } finally { + this.pairing = false; + } + }, + + closeQrModal() { + this.showQrModal = false; + this.pairingResult = null; + }, + + // ---- Revoke flow ---- + + confirmRevoke(device) { + this.targetDevice = device; + this.revokeConfirmMessage = I18n.t('loyalty.terminal_devices.confirm_revoke', { label: device.label }); + this.showRevokeModal = true; + }, + + async revokeDevice() { + if (!this.targetDevice) return; + try { + await apiClient.post(config.apiPrefix + '/devices/' + this.targetDevice.id + '/revoke', {}); + Utils.showToast(I18n.t('loyalty.terminal_devices.toasts.revoked'), 'success'); + this.showRevokeModal = false; + this.targetDevice = null; + await this.loadDevices(); + } catch (error) { + loyaltyDevicesListLog.error('Failed to revoke device:', error); + Utils.showToast(error.message || I18n.t('loyalty.terminal_devices.errors.revoke_failed'), 'error'); + } + }, + + // ---- Delete flow ---- + + confirmDelete(device) { + this.targetDevice = device; + this.deleteConfirmMessage = I18n.t('loyalty.terminal_devices.confirm_delete', { label: device.label }); + this.showDeleteModal = true; + }, + + async deleteDevice() { + if (!this.targetDevice) return; + try { + await apiClient.delete(config.apiPrefix + '/devices/' + this.targetDevice.id); + Utils.showToast(I18n.t('loyalty.terminal_devices.toasts.deleted'), 'success'); + this.showDeleteModal = false; + this.targetDevice = null; + await this.loadDevices(); + } catch (error) { + loyaltyDevicesListLog.error('Failed to delete device:', error); + Utils.showToast(error.message || I18n.t('loyalty.terminal_devices.errors.delete_failed'), 'error'); + } + }, + }; +} + +if (!window.LogConfig.loggers.loyaltyDevicesList) { + window.LogConfig.loggers.loyaltyDevicesList = window.LogConfig.createLogger('loyaltyDevicesList'); +} +loyaltyDevicesListLog.info('Loyalty devices list factory loaded'); diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-devices.html b/app/modules/loyalty/templates/loyalty/admin/merchant-devices.html new file mode 100644 index 00000000..cf255d7b --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-devices.html @@ -0,0 +1,27 @@ +{# app/modules/loyalty/templates/loyalty/admin/merchant-devices.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import detail_page_header, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.terminal_devices.title') }}{% endblock %} +{% block i18n_modules %}['loyalty']{% endblock %} +{% block alpine_data %}adminMerchantDevices(){% endblock %} + +{% block content %} +{% call detail_page_header("'" + _('loyalty.terminal_devices.title') + ": " + (merchant_name or '') + "'", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='program') %} + {{ _('loyalty.terminal_devices.admin_subtitle') }} +{% endcall %} + +{{ loading_state(_('loyalty.terminal_devices.loading')) }} +{{ error_state(_('loyalty.terminal_devices.error_loading')) }} + +{% set devices_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %} +{% set show_store_filter = true %} +{% set show_crud = true %} +{% include 'loyalty/shared/devices-list.html' %} +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/merchant/devices.html b/app/modules/loyalty/templates/loyalty/merchant/devices.html new file mode 100644 index 00000000..e9a56d44 --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/merchant/devices.html @@ -0,0 +1,36 @@ +{# app/modules/loyalty/templates/loyalty/merchant/devices.html #} +{% extends "merchant/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}{{ _('loyalty.terminal_devices.title') }}{% endblock %} + +{% block i18n_modules %}['loyalty']{% endblock %} + +{% block alpine_data %}merchantLoyaltyDevices(){% endblock %} + +{% block content %} +{% call page_header_flex(title=_('loyalty.terminal_devices.title'), subtitle=_('loyalty.terminal_devices.subtitle')) %} +
+ {{ refresh_button(loading_var='loading', onclick='loadDevices()', variant='secondary') }} + +
+{% endcall %} + +{{ loading_state(_('loyalty.terminal_devices.loading')) }} +{{ error_state(_('loyalty.terminal_devices.error_loading')) }} + +{% set devices_api_prefix = '/merchants/loyalty' %} +{% set show_store_filter = true %} +{% set show_crud = true %} +{% include 'loyalty/shared/devices-list.html' %} +{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/app/modules/loyalty/templates/loyalty/shared/devices-list.html b/app/modules/loyalty/templates/loyalty/shared/devices-list.html new file mode 100644 index 00000000..6fd3598a --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/shared/devices-list.html @@ -0,0 +1,192 @@ +{# app/modules/loyalty/templates/loyalty/shared/devices-list.html #} +{# + Shared terminal-devices list partial. Set these variables before including: + - devices_api_prefix (str): API base URL for device data + - show_store_filter (bool): Show store dropdown filter (true for merchant view) + - show_crud (bool): Show pair/revoke/delete actions (false for admin read-only) +#} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} +{% from 'shared/macros/modals.html' import modal, confirm_modal_dynamic %} + + +
+
+ {% if show_store_filter %} + + {% endif %} + +
+
+ + +
+ {% call table_wrapper() %} + {% if show_store_filter %} + {{ table_header([ + _('loyalty.terminal_devices.col_label'), + _('loyalty.terminal_devices.col_store'), + _('loyalty.terminal_devices.col_status'), + _('loyalty.terminal_devices.col_last_seen'), + _('loyalty.terminal_devices.col_expires'), + _('loyalty.terminal_devices.col_actions'), + ]) }} + {% else %} + {{ table_header([ + _('loyalty.terminal_devices.col_label'), + _('loyalty.terminal_devices.col_status'), + _('loyalty.terminal_devices.col_last_seen'), + _('loyalty.terminal_devices.col_expires'), + _('loyalty.terminal_devices.col_actions'), + ]) }} + {% endif %} + + + + + {% endcall %} +
+ +{% if show_crud %} + +{% call modal('pairDeviceModal', _('loyalty.terminal_devices.pair_device'), 'showPairModal', size='md', show_footer=false) %} +
+
+
+ + +
+ {% if show_store_filter %} +
+ + +
+ {% endif %} +

{{ _('loyalty.terminal_devices.pair_hint') }}

+
+
+ + +
+
+{% endcall %} + + +{% call modal('pairingQrModal', _('loyalty.terminal_devices.pairing_qr'), 'showQrModal', size='md', show_footer=false, close_on_backdrop=false) %} +
+
+

+ {{ _('loyalty.terminal_devices.qr_warning_title') }} + {{ _('loyalty.terminal_devices.qr_warning_body') }} +

+
+
+ Pairing QR code +
+
+ {{ _('loyalty.terminal_devices.show_payload') }} +

+        
+
+ +
+
+{% endcall %} + + +{{ confirm_modal_dynamic( + 'revokeDeviceModal', + _('loyalty.terminal_devices.revoke_title'), + 'revokeConfirmMessage', + 'revokeDevice()', + 'showRevokeModal', + confirm_text=_('loyalty.terminal_devices.revoke'), + cancel_text=_('loyalty.common.cancel'), + variant='warning' +) }} + + +{{ confirm_modal_dynamic( + 'deleteDeviceModal', + _('loyalty.terminal_devices.delete_title'), + 'deleteConfirmMessage', + 'deleteDevice()', + 'showDeleteModal', + confirm_text=_('loyalty.common.delete'), + cancel_text=_('loyalty.common.cancel'), + variant='danger' +) }} +{% endif %} diff --git a/app/modules/loyalty/tests/integration/test_terminal_devices.py b/app/modules/loyalty/tests/integration/test_terminal_devices.py new file mode 100644 index 00000000..0080161b --- /dev/null +++ b/app/modules/loyalty/tests/integration/test_terminal_devices.py @@ -0,0 +1,186 @@ +# app/modules/loyalty/tests/integration/test_terminal_devices.py +"""Integration tests for terminal device pairing & revocation. + +Covers both the merchant API (`/api/v1/merchants/loyalty/devices/...`) and +the admin-on-behalf API (`/api/v1/admin/loyalty/merchants/{id}/devices/...`), +plus the bearer-auth path on the store API that accepts a paired-device JWT. +""" + +import pytest +from jose import jwt + +from app.modules.loyalty.models import TerminalDevice +from middleware.auth import AuthManager + +MERCHANT_BASE = "/api/v1/merchants/loyalty" +STORE_BASE = "/api/v1/store/loyalty" + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantPairing: + """POST /merchants/loyalty/devices — creates row and returns one-time QR.""" + + def test_pair_device_returns_setup_token_and_qr( + self, client, loyalty_merchant_headers, loyalty_store_setup, db + ): + store = loyalty_store_setup["store"] + response = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": store.id, "label": "Counter 1"}, + headers=loyalty_merchant_headers, + ) + assert response.status_code == 201, response.text + data = response.json() + + assert data["label"] == "Counter 1" + assert data["store_id"] == store.id + assert data["status"] == "active" + assert data["setup_token"] + assert data["qr_png_base64"].startswith("data:image/png;base64,") + assert data["setup_payload"]["store_code"] == store.store_code + assert data["setup_payload"]["auth_token"] == data["setup_token"] + + # Token is signed by AuthManager and carries the device_setup claim + # plus the same jti as the row. + auth = AuthManager() + decoded = jwt.decode( + data["setup_token"], auth.secret_key, algorithms=[auth.algorithm] + ) + assert decoded["device_setup"] is True + assert decoded["store_id"] == store.id + device = ( + db.query(TerminalDevice) + .filter(TerminalDevice.id == data["id"]) + .first() + ) + assert device is not None + assert device.jti == decoded["jti"] + + def test_pair_device_rejects_foreign_store( + self, client, loyalty_merchant_headers, db + ): + """A merchant cannot pair against a store they don't own.""" + # store_id=99999 doesn't belong to this merchant + response = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": 99999, "label": "Hijack"}, + headers=loyalty_merchant_headers, + ) + assert response.status_code == 404 + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantListAndRevoke: + """GET / POST revoke — listing and lifecycle.""" + + def test_list_excludes_revoked_by_default( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + store = loyalty_store_setup["store"] + # Pair two devices + for label in ("A", "B"): + r = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": store.id, "label": label}, + headers=loyalty_merchant_headers, + ) + assert r.status_code == 201 + # Revoke first one + listing = client.get( + f"{MERCHANT_BASE}/devices", headers=loyalty_merchant_headers + ).json() + first_id = listing["devices"][-1]["id"] # deterministic order: created_at desc + r = client.post( + f"{MERCHANT_BASE}/devices/{first_id}/revoke", + json={"reason": "lost"}, + headers=loyalty_merchant_headers, + ) + assert r.status_code == 200 + assert r.json()["status"] == "revoked" + + # Default list excludes the revoked one + active = client.get( + f"{MERCHANT_BASE}/devices", headers=loyalty_merchant_headers + ).json() + assert active["total"] == 1 + + # include_revoked=true brings it back + full = client.get( + f"{MERCHANT_BASE}/devices?include_revoked=true", + headers=loyalty_merchant_headers, + ).json() + assert full["total"] == 2 + + def test_revoke_is_idempotent( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + store = loyalty_store_setup["store"] + r = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": store.id, "label": "Idem"}, + headers=loyalty_merchant_headers, + ).json() + first = client.post( + f"{MERCHANT_BASE}/devices/{r['id']}/revoke", + json={}, + headers=loyalty_merchant_headers, + ).json() + second = client.post( + f"{MERCHANT_BASE}/devices/{r['id']}/revoke", + json={}, + headers=loyalty_merchant_headers, + ).json() + assert first["revoked_at"] == second["revoked_at"] + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestStoreAPIBearerAuth: + """Paired-device JWT is accepted on the store API.""" + + def test_device_token_authenticates_store_api( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + store = loyalty_store_setup["store"] + paired = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": store.id, "label": "Counter 1"}, + headers=loyalty_merchant_headers, + ).json() + token = paired["setup_token"] + + response = client.get( + f"{STORE_BASE}/program", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["merchant_id"] == loyalty_store_setup["merchant"].id + + def test_revoked_device_token_is_rejected( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + store = loyalty_store_setup["store"] + paired = client.post( + f"{MERCHANT_BASE}/devices", + json={"store_id": store.id, "label": "Burn"}, + headers=loyalty_merchant_headers, + ).json() + token = paired["setup_token"] + + client.post( + f"{MERCHANT_BASE}/devices/{paired['id']}/revoke", + json={"reason": "test"}, + headers=loyalty_merchant_headers, + ) + + response = client.get( + f"{STORE_BASE}/program", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code in (400, 401) diff --git a/app/modules/tenancy/schemas/auth.py b/app/modules/tenancy/schemas/auth.py index b95bc71a..f5b7a783 100644 --- a/app/modules/tenancy/schemas/auth.py +++ b/app/modules/tenancy/schemas/auth.py @@ -249,6 +249,11 @@ class UserContext(BaseModel): token_store_code: str | None = None token_store_role: str | None = None + # Set when the request was authenticated by a paired POS terminal device + # rather than a human user logging in. The device's row carries the actual + # principal for audit; this surfaces it for endpoints that care. + terminal_device_id: int | None = None + # Optional profile fields first_name: str | None = None last_name: str | None = None @@ -344,4 +349,8 @@ class UserContext(BaseModel): data["token_store_code"] = getattr(user, "token_store_code", None) data["token_store_role"] = getattr(user, "token_store_role", None) + # Surface terminal-device principal if the user object came from a + # device-token authentication path. + data["terminal_device_id"] = getattr(user, "terminal_device_id", None) + return cls(**data) diff --git a/middleware/auth.py b/middleware/auth.py index 22f64bf8..9ec4d7c2 100644 --- a/middleware/auth.py +++ b/middleware/auth.py @@ -206,6 +206,57 @@ class AuthManager: "expires_in": self.token_expire_minutes * 60, # Convert minutes to seconds } + def create_device_token( + self, + *, + device_id: int, + jti: str, + merchant_id: int, + store_id: int, + store_code: str, + expires_at: datetime, + ) -> str: + """Create a long-lived JWT for a paired terminal device. + + Carries `device_setup: True` to distinguish from user tokens, plus + `jti` so the device row remains the source of truth for revocation. + Returns the encoded token only — caller stores `jti`, never the token. + """ + payload = { + "sub": str(device_id), + "jti": jti, + "device_setup": True, + "merchant_id": merchant_id, + "store_id": store_id, + "store_code": store_code, + "exp": expires_at, + "iat": datetime.now(UTC), + } + return jwt.encode(payload, self.secret_key, algorithm=self.algorithm) + + def verify_device_token(self, token: str) -> dict[str, Any]: + """Verify a device JWT and return its payload. + + Validates signature, expiration, the `device_setup` claim, and the + presence of jti / store_id. Raises InvalidTokenException for any + non-device or malformed token, TokenExpiredException for expired. + """ + try: + payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) + except jwt.ExpiredSignatureError: + raise TokenExpiredException() + except jwt.JWTError as e: + logger.error(f"Device JWT decode error: {e}") + raise InvalidTokenException("Could not validate device token") + + if not payload.get("device_setup"): + raise InvalidTokenException("Not a device token") + if not payload.get("jti"): + raise InvalidTokenException("Device token missing jti") + if payload.get("store_id") is None: + raise InvalidTokenException("Device token missing store context") + return payload + def verify_token(self, token: str) -> dict[str, Any]: """Verify and decode a JWT token, returning the user data. diff --git a/requirements.txt b/requirements.txt index 23ae6d82..cc85ae31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ beautifulsoup4==4.14.3 # Image processing Pillow>=10.0.0 +qrcode>=7.4.0 # System monitoring psutil>=5.9.0