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

@@ -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');