feat(loyalty): pair POS terminal devices with one-time setup QR
Some checks failed
Some checks failed
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
127
app/modules/loyalty/models/terminal_device.py
Normal file
127
app/modules/loyalty/models/terminal_device.py
Normal file
@@ -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"<TerminalDevice(id={self.id}, label='{self.label}', "
|
||||
f"store_id={self.store_id}, revoked={self.revoked_at is not None})>"
|
||||
)
|
||||
|
||||
@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"
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
72
app/modules/loyalty/schemas/terminal_device.py
Normal file
72
app/modules/loyalty/schemas/terminal_device.py
Normal file
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
255
app/modules/loyalty/services/terminal_device_service.py
Normal file
255
app/modules/loyalty/services/terminal_device_service.py
Normal file
@@ -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()
|
||||
@@ -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');
|
||||
16
app/modules/loyalty/static/merchant/js/loyalty-devices.js
Normal file
16
app/modules/loyalty/static/merchant/js/loyalty-devices.js
Normal file
@@ -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');
|
||||
205
app/modules/loyalty/static/shared/js/loyalty-devices-list.js
Normal file
205
app/modules/loyalty/static/shared/js/loyalty-devices-list.js
Normal file
@@ -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');
|
||||
@@ -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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-devices-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-devices.js') }}"></script>
|
||||
{% endblock %}
|
||||
36
app/modules/loyalty/templates/loyalty/merchant/devices.html
Normal file
36
app/modules/loyalty/templates/loyalty/merchant/devices.html
Normal file
@@ -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')) %}
|
||||
<div class="flex items-center gap-3">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadDevices()', variant='secondary') }}
|
||||
<button @click="openPairModal()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.terminal_devices.pair_device') }}
|
||||
</button>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-devices-list.js') }}"></script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-devices.js') }}"></script>
|
||||
{% endblock %}
|
||||
192
app/modules/loyalty/templates/loyalty/shared/devices-list.html
Normal file
192
app/modules/loyalty/templates/loyalty/shared/devices-list.html
Normal file
@@ -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 %}
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
{% if show_store_filter %}
|
||||
<select x-model="filters.store_id" @change="applyFilter()"
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.common.all_stores') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
{% endif %}
|
||||
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" x-model="filters.include_revoked" @change="applyFilter()"
|
||||
class="mr-2 rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
{{ _('loyalty.terminal_devices.show_revoked') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Devices Table -->
|
||||
<div x-show="!loading">
|
||||
{% 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 %}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="devices.length === 0">
|
||||
<tr>
|
||||
<td :colspan="'{{ '6' if show_store_filter else '5' }}'" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('device-tablet', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">{{ _('loyalty.terminal_devices.no_devices') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="device in devices" :key="device.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3 text-sm font-semibold" x-text="device.label"></td>
|
||||
{% if show_store_filter %}
|
||||
<td class="px-4 py-3 text-sm" x-text="device.store_name || '-'"></td>
|
||||
{% endif %}
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': device.status === 'active',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': device.status === 'revoked',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': device.status === 'expired'
|
||||
}"
|
||||
x-text="$t('loyalty.terminal_devices.status_' + device.status)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" x-text="device.last_seen_at ? formatDate(device.last_seen_at) : '-'"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(device.expires_at)"></td>
|
||||
<td class="px-4 py-3">
|
||||
{% if show_crud %}
|
||||
<div class="flex items-center gap-2">
|
||||
<button x-show="device.status === 'active'" @click="confirmRevoke(device)" type="button"
|
||||
aria-label="{{ _('loyalty.terminal_devices.revoke') }}"
|
||||
class="text-orange-600 hover:text-orange-700 dark:text-orange-400 text-sm">
|
||||
<span x-html="$icon('lock-closed', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button @click="confirmDelete(device)" type="button"
|
||||
aria-label="{{ _('loyalty.common.delete') }}"
|
||||
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
{% if show_crud %}
|
||||
<!-- Pair Device Modal -->
|
||||
{% call modal('pairDeviceModal', _('loyalty.terminal_devices.pair_device'), 'showPairModal', size='md', show_footer=false) %}
|
||||
<form @submit.prevent="pairDevice()">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.terminal_devices.label_field') }}</label>
|
||||
<input type="text" x-model="pairForm.label" required minlength="1" maxlength="100"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
placeholder="{{ _('loyalty.terminal_devices.label_placeholder') }}">
|
||||
</div>
|
||||
{% if show_store_filter %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.terminal_devices.store_field') }}</label>
|
||||
<select x-model="pairForm.store_id" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.terminal_devices.select_store') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ _('loyalty.terminal_devices.pair_hint') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 mt-6">
|
||||
<button type="button" @click="showPairModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
|
||||
{{ _('loyalty.common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="pairing"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="pairing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="pairing ? $t('loyalty.common.saving') : $t('loyalty.terminal_devices.pair_device')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
<!-- QR Code Modal (one-time pairing reveal) -->
|
||||
{% call modal('pairingQrModal', _('loyalty.terminal_devices.pairing_qr'), 'showQrModal', size='md', show_footer=false, close_on_backdrop=false) %}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 p-3">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>{{ _('loyalty.terminal_devices.qr_warning_title') }}</strong>
|
||||
{{ _('loyalty.terminal_devices.qr_warning_body') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-white p-4 rounded-lg" x-show="pairingResult">
|
||||
<img :src="pairingResult.qr_png_base64" alt="Pairing QR code" class="w-64 h-64">
|
||||
</div>
|
||||
<details class="text-sm" x-show="pairingResult">
|
||||
<summary class="cursor-pointer text-gray-600 dark:text-gray-400">{{ _('loyalty.terminal_devices.show_payload') }}</summary>
|
||||
<pre class="mt-2 p-3 text-xs bg-gray-50 dark:bg-gray-900 rounded overflow-x-auto" x-text="JSON.stringify(pairingResult.setup_payload, null, 2)"></pre>
|
||||
</details>
|
||||
<div class="flex items-center justify-end">
|
||||
<button @click="closeQrModal()" type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
{{ _('loyalty.terminal_devices.qr_done') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Revoke Confirm -->
|
||||
{{ 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'
|
||||
) }}
|
||||
|
||||
<!-- Delete Confirm -->
|
||||
{{ 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 %}
|
||||
186
app/modules/loyalty/tests/integration/test_terminal_devices.py
Normal file
186
app/modules/loyalty/tests/integration/test_terminal_devices.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ beautifulsoup4==4.14.3
|
||||
|
||||
# Image processing
|
||||
Pillow>=10.0.0
|
||||
qrcode>=7.4.0
|
||||
|
||||
# System monitoring
|
||||
psutil>=5.9.0
|
||||
|
||||
Reference in New Issue
Block a user