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
|
||||
|
||||
Reference in New Issue
Block a user