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:
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');
|
||||
Reference in New Issue
Block a user