import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import '../../domain/entities/pos_session.dart'; /// Raised when the back office refuses or cannot answer a sign-in. /// /// Carries a message meant to be shown to whoever is standing at the till, so /// it is written for them rather than for a log: what happened, and what they /// can do about it. class PosAuthException implements Exception { const PosAuthException(this.message, {this.isCredentialFailure = false}); final String message; /// Whether the details were wrong, as opposed to the back office being /// unreachable. The till reacts differently: a bad password is worth /// re-typing, an unreachable server is worth waiting for. final bool isCredentialFailure; @override String toString() => message; } /// Signs a terminal in against the back office. /// /// Talks to the same `app_users` accounts as the web console, so a manager who /// can open the back office can open the till with the same details — one /// account store means deactivating a leaver closes both doors at once. /// /// ``` /// POST {base}/login /// { "authname": "…", "password": "…", "terminal_id": "T5EDD" } /// ``` /// /// answered with `{ code, status, details: { token, store_id, locations, … } }`. class PosAuthApi { PosAuthApi({required this.baseUrl, http.Client? client}) : _client = client ?? http.Client(); final String baseUrl; final http.Client _client; /// Generous, because this runs on a shop's connection while somebody watches. /// Short enough that a dead endpoint is reported rather than hung on. static const _timeout = Duration(seconds: 20); /// Exchanges credentials for a session. /// /// [locationId] is only meaningful for an account entitled to several /// outlets: it says which one this terminal is standing in. It is a request, /// not an assertion — the back office checks it against what the account may /// actually reach, and that check is the whole point of the endpoint. Future login({ required String authname, required String password, String? terminalId, String? deviceId, int? locationId, int? configId, }) async { if (baseUrl.isEmpty) { throw const PosAuthException( 'This terminal has no back office configured. Set the endpoint in ' 'Settings → Connectivity & sync.', ); } final body = { 'authname': authname.trim(), 'password': password, if (terminalId != null && terminalId.isNotEmpty) 'terminal_id': terminalId, if (deviceId != null && deviceId.isNotEmpty) 'device_id': deviceId, if (locationId != null && locationId > 0) 'location_id': locationId, // Sent only when known. The backend infers it when absent, and a shop // has no way to find out what its configid is. if (configId != null && configId > 0) 'configid': configId, }; final http.Response response; try { response = await _client .post( Uri.parse('$baseUrl/login'), headers: const {'Content-Type': 'application/json'}, body: jsonEncode(body), ) .timeout(_timeout); } on TimeoutException { throw const PosAuthException( 'The back office did not answer in time. Check the connection and try ' 'again.', ); } on Object { throw const PosAuthException( 'Could not reach the back office. Check the connection and try again.', ); } Map decoded; try { decoded = jsonDecode(response.body) as Map; } on Object { throw PosAuthException( 'The back office answered with something this terminal could not read ' '(HTTP ${response.statusCode}).', ); } if (response.statusCode != 200) { throw PosAuthException( (decoded['message'] as String?) ?? 'Sign-in was refused (HTTP ${response.statusCode}).', // 401 is a wrong email or password; 403 is a real account that may not // open this till. Only the first is worth re-typing. isCredentialFailure: response.statusCode == 401, ); } final details = decoded['details']; if (details is! Map) { throw const PosAuthException( 'The back office accepted the sign-in but returned no session.', ); } final session = PosSession.fromJson(details); // A session with no token cannot authenticate anything, and one with no // outlet cannot bill. Refused here rather than being saved and failing // later against every request, which would be much harder to diagnose. if (session.token.isEmpty) { throw const PosAuthException( 'The back office returned a session with no token.', ); } if (session.locationId <= 0) { throw const PosAuthException( 'This account is not attached to an outlet, so it cannot open a till.', ); } return session; } void dispose() => _client.close(); }