feat(loyalty): pair POS terminal devices with one-time setup QR
Some checks failed
CI / ruff (push) Successful in 47s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

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:
2026-05-05 20:18:57 +02:00
parent c7ab5eb900
commit 6276e9e3ac
28 changed files with 1971 additions and 7 deletions

View File

@@ -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