added login

This commit is contained in:
2026-08-07 17:07:49 +05:30
parent ad44402232
commit 4a2474ee6c
8 changed files with 908 additions and 383 deletions

View File

@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../domain/entities/pos_session.dart';
/// Where the signed-in session lives between launches.
///
/// The platform keystore, not SQLite — Keychain on macOS, Credential Manager
/// on Windows, the Android Keystore on a tablet. The response carries a bearer
/// token and every staff PIN in the clear, and the SQLite file sits on a
/// machine behind a shop counter readable by anything that can open it.
///
/// Stored as one blob rather than field by field so [clear] is a single
/// delete. A sign-out that leaves half a session behind is worse than one that
/// leaves none.
class SessionStore {
SessionStore({FlutterSecureStorage? secureStorage})
: _secure = secureStorage ?? const FlutterSecureStorage();
final FlutterSecureStorage _secure;
static const String _key = 'pos.session';
/// The stored session, or null if there is none, it cannot be read, or it
/// has expired.
///
/// An expired token is treated as absent and swept: carrying it forward only
/// moves the failure to the first call that uses it, which is a cashier
/// discovering it mid-sale rather than at the login screen.
Future<PosSession?> read() async {
String? raw;
try {
raw = await _secure.read(key: _key);
} on Object catch (e) {
// No keystore — a headless test host, or a Linux box with no secret
// service. The terminal still runs, it just asks for credentials.
debugPrint('SessionStore: keystore unavailable ($e)');
return null;
}
if (raw == null || raw.isEmpty) return null;
PosSession session;
try {
session = PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
} on Object catch (e) {
// A blob this build cannot parse — an upgrade that changed the shape.
// Drop it rather than failing every launch from here on.
debugPrint('SessionStore: unreadable session dropped ($e)');
await clear();
return null;
}
if (session.token.isEmpty || session.isExpired) {
await clear();
return null;
}
return session;
}
Future<void> save(PosSession session) async {
try {
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
} on Object catch (e) {
// Not fatal: the session is live in memory and this shift carries on.
// The next launch just asks for credentials again.
debugPrint('SessionStore: could not persist session ($e)');
}
}
/// Removes the session. Called on every sign-out, and on an expired or
/// rejected token.
Future<void> clear() async {
try {
await _secure.delete(key: _key);
} on Object catch (e) {
debugPrint('SessionStore: could not clear session ($e)');
}
}
}

View File

@@ -0,0 +1,163 @@
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();
}