164 lines
4.9 KiB
Dart
164 lines
4.9 KiB
Dart
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<PosSession> 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<String, Object?> body;
|
|
try {
|
|
body = jsonDecode(response.body) as Map<String, Object?>;
|
|
} 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<String, Object?>) {
|
|
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();
|
|
}
|