import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import '../../domain/entities/pos_session.dart'; /// A sign-in that did not produce a session. /// /// Carries a message written for the person at the counter, not a status code. /// [isCredentialFailure] separates "you typed the wrong password" from "the /// shop's internet is down", because the first is the operator's problem to /// fix and the second is not. class AuthApiException implements Exception { const AuthApiException( this.message, { this.isCredentialFailure = false, this.statusCode, }); final String message; final bool isCredentialFailure; final int? statusCode; @override String toString() => message; } /// Signs the terminal in against the back office. /// /// ``` /// POST {base}/login /// Content-Type: application/json /// /// { "authname": …, "password": …, "device_id": …, "configid": 1 } /// ``` /// /// answering /// /// ```json /// { "code": 200, "status": true, "message": "Login successful", /// "details": { "token": …, "role": "Supervisor", … } } /// ``` /// /// The envelope is checked rather than the HTTP status alone: this API answers /// `200` with `status: false` for a rejected credential, so trusting the /// status code would sign a terminal in on a failed login. class PosAuthApi { PosAuthApi({ required this.baseUrl, this.configId = 1, http.Client? client, }) : _client = client ?? http.Client(); /// Same base as the catalogue and order endpoints, e.g. /// `https://fiesta.nearle.app/live/api/v1/pos`. final String baseUrl; /// Which back-office configuration this terminal belongs to. final int configId; final http.Client _client; static const Duration _timeout = Duration(seconds: 20); Future login({ required String authname, required String password, required String deviceId, }) async { if (baseUrl.isEmpty) { throw const AuthApiException( 'No back-office URL is configured for this terminal. Set one in ' 'Settings → Connectivity & sync → Configure.', ); } final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/+$'), '')}/login'); http.Response response; try { response = await _client .post( uri, headers: const { 'content-type': 'application/json', 'accept': 'application/json', }, body: jsonEncode({ 'authname': authname.trim(), 'password': password, // This device's own identity, minted on first run. Two terminals // must never sign in as the same device — the back office keys // sessions on it. 'device_id': deviceId, 'configid': configId, }), ) .timeout(_timeout); } on TimeoutException { throw const AuthApiException( 'The back office did not answer in time. Check the connection and ' 'try again.', ); } on http.ClientException { // DNS failure, refused connection, dropped socket — the shop's line // rather than the operator's credentials. throw const AuthApiException( 'Could not reach the back office. Check this terminal\'s internet ' 'connection.', ); } on Exception catch (e) { throw AuthApiException('Could not reach the back office: $e'); } Map body; try { body = jsonDecode(response.body) as Map; } on Object { throw AuthApiException( 'The back office answered with something this terminal could not ' 'read (${response.statusCode}).', statusCode: response.statusCode, ); } final ok = body['status'] == true && response.statusCode < 300; if (!ok) { final raw = body['message']; final message = raw is String ? raw.trim() : ''; const rejectedCodes = {400, 401, 403, 422}; throw AuthApiException( // The server's own wording, when it gave one. It knows whether the // account is disabled, the device is unregistered or the password is // simply wrong, and a generic message would throw that away. message.isEmpty ? 'Sign-in failed (${response.statusCode}).' : message, isCredentialFailure: rejectedCodes.contains(response.statusCode), statusCode: response.statusCode, ); } final details = body['details']; if (details is! Map) { throw const AuthApiException( 'The back office accepted the sign-in but sent no session back.', ); } final session = PosSession.fromDetails(details, authname: authname.trim()); if (session.token.isEmpty) { throw const AuthApiException( 'The back office accepted the sign-in but issued no token.', ); } return session; } void dispose() => _client.close(); }