check
This commit is contained in:
@@ -76,6 +76,17 @@ class LocalStore {
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
/// Re-reads the terminal's identity after it has been re-pointed at another
|
||||
/// outlet.
|
||||
///
|
||||
/// [terminal] is cached on this object because every bill, topic and presence
|
||||
/// record reads it — but signing in can change which store the till belongs
|
||||
/// to, and the cached copy would go on naming the old one until a restart.
|
||||
Future<TerminalIdentity> reloadTerminal() async {
|
||||
terminal = await identityStore.load();
|
||||
return terminal;
|
||||
}
|
||||
|
||||
/// Re-reads cached state from disk. Called on start and after an import.
|
||||
Future<void> hydrate() async {
|
||||
_products
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../domain/entities/pos_session.dart';
|
||||
|
||||
/// Keeps a terminal signed in across restarts.
|
||||
///
|
||||
/// A till is not a browser. It signs in when a shop opens and bills for the
|
||||
/// whole trading day, often on a connection that comes and goes, and it must
|
||||
/// survive being rebooted mid-shift without a queue of customers waiting while
|
||||
/// somebody finds the manager's password.
|
||||
///
|
||||
/// The whole session goes to the platform keystore rather than to SQLite. The
|
||||
/// token is a bearer credential — anything holding it can bill as this shop —
|
||||
/// and SQLite here is a file on a machine behind a shop counter, readable by
|
||||
/// anything that can open it. The rest of the session travels with the token
|
||||
/// because splitting them invites the two halves to disagree about which outlet
|
||||
/// this terminal is.
|
||||
class SessionStore {
|
||||
SessionStore({FlutterSecureStorage? secureStorage})
|
||||
: _secure = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _secure;
|
||||
|
||||
static const _key = 'pos.session';
|
||||
|
||||
/// Reads the saved session, or null when there is none worth using.
|
||||
///
|
||||
/// An expired session is treated as absent rather than returned for the
|
||||
/// caller to check. Every caller would have to make the same check, and the
|
||||
/// one that forgot would send a dead token all day and read the resulting
|
||||
/// 401s as a server fault.
|
||||
Future<PosSession?> read({DateTime? now}) async {
|
||||
final 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. Signing in again is the safe way to fail.
|
||||
debugPrint('Secure storage unavailable, session not loaded: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final session =
|
||||
PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
|
||||
if (!session.isValidAt(now ?? DateTime.now())) return null;
|
||||
if (session.token.isEmpty || session.locationId <= 0) return null;
|
||||
return session;
|
||||
} on Object catch (e) {
|
||||
// A stored session this build cannot parse — most likely written by an
|
||||
// older one. Dropped rather than repaired: a half-understood session is
|
||||
// worse than none, and re-authenticating costs one screen.
|
||||
debugPrint('Stored session could not be read, discarding: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write(PosSession session) async {
|
||||
try {
|
||||
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
|
||||
} on Object catch (e) {
|
||||
// The terminal keeps working on the session it holds in memory; it just
|
||||
// will not survive a restart. Failing the sign-in over this would close a
|
||||
// shop for a keystore problem.
|
||||
debugPrint('Could not persist the session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
await _secure.delete(key: _key);
|
||||
} on Object catch (e) {
|
||||
debugPrint('Could not clear the session: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,6 @@ class StaffDao {
|
||||
|
||||
final Database _db;
|
||||
|
||||
/// Exposed for [StaffImport], which lives in this file and is part of this
|
||||
/// type in everything but syntax — an extension cannot see a private field.
|
||||
Database get db => _db;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// The accounts a shop starts with.
|
||||
@@ -150,6 +146,87 @@ class StaffDao {
|
||||
);
|
||||
}
|
||||
|
||||
/// Writes a staff member the back office owns, creating or updating the row.
|
||||
///
|
||||
/// Deliberately skips [_assertPinIsAcceptable] and the duplicate-PIN check.
|
||||
/// Those rules exist to stop *this terminal* from accepting a weak PIN
|
||||
/// someone typed at the counter; they are not this terminal's to enforce on
|
||||
/// a list the back office has already published. Applying them here would
|
||||
/// mean a shop whose head office issued `1111` simply never receives its
|
||||
/// staff, and the till falls back to seeded demo accounts — a worse outcome
|
||||
/// than a guessable PIN.
|
||||
///
|
||||
/// [id] is derived from the server's `user_id` rather than minted, so a
|
||||
/// second sign-in updates the same row instead of duplicating the person.
|
||||
///
|
||||
/// A null [pin] leaves an existing PIN alone, and on a new row stores an
|
||||
/// unusable hash: the person appears on the staff list and can be attributed
|
||||
/// bills, but nothing typed at the keypad will ever match them. That is the
|
||||
/// honest representation of "the back office did not give us their PIN".
|
||||
Future<StaffUser> upsertFromServer({
|
||||
required String id,
|
||||
required String name,
|
||||
required StaffRole role,
|
||||
String? pin,
|
||||
bool isActive = true,
|
||||
}) async {
|
||||
final trimmed = name.trim().isEmpty ? 'Staff' : name.trim();
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final existing = await findById(id);
|
||||
|
||||
// Minted once and reused for both columns. PinHasher.newSalt() is random,
|
||||
// so calling it twice in one statement would store a hash the stored salt
|
||||
// cannot reproduce.
|
||||
final salt = PinHasher.newSalt();
|
||||
|
||||
if (existing != null) {
|
||||
await _db.update(
|
||||
Tables.staff,
|
||||
{
|
||||
'name': trimmed,
|
||||
'role': role.name,
|
||||
'is_active': isActive ? 1 : 0,
|
||||
if (pin != null) ...{
|
||||
'pin_hash': PinHasher.hash(pin, salt),
|
||||
'pin_salt': salt,
|
||||
'must_change_pin': 0,
|
||||
},
|
||||
'updated_at': now,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
return StaffUser(id: id, name: trimmed, role: role, isActive: isActive);
|
||||
}
|
||||
|
||||
// No PIN from the server means no PIN that can ever be entered: hashing a
|
||||
// random value is how that is stored, rather than a sentinel a future
|
||||
// reader might treat as "any PIN accepted".
|
||||
final secret = pin ?? _uuid.v4();
|
||||
|
||||
await _db.insert(
|
||||
Tables.staff,
|
||||
{
|
||||
'id': id,
|
||||
'name': trimmed,
|
||||
'role': role.name,
|
||||
'pin_hash': PinHasher.hash(secret, salt),
|
||||
'pin_salt': salt,
|
||||
'must_change_pin': 0,
|
||||
'is_active': isActive ? 1 : 0,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
return StaffUser(id: id, name: trimmed, role: role, isActive: isActive);
|
||||
}
|
||||
|
||||
/// A stable local id for a back-office user, so a second sign-in updates the
|
||||
/// same row instead of duplicating the person.
|
||||
static String serverId(int userId) => 'srv-$userId';
|
||||
|
||||
Future<void> updateDetails({
|
||||
required String id,
|
||||
String? name,
|
||||
@@ -281,93 +358,3 @@ class StaffDao {
|
||||
isActive: (row['is_active'] as int? ?? 1) == 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaces the terminal's staff with what the back office says.
|
||||
///
|
||||
/// The back office is the source of truth for who works at a shop, and this is
|
||||
/// where that becomes true rather than aspirational. It exists because the
|
||||
/// alternative — three names and three PINs compiled into the app — meant every
|
||||
/// install of a build shared the same three logins, readable by anyone with the
|
||||
/// APK.
|
||||
///
|
||||
/// Three things happen, and the second is the one that matters:
|
||||
///
|
||||
/// 1. every person the back office named is written, keyed on their user id so
|
||||
/// a re-sync updates rather than duplicates;
|
||||
/// 2. **the seeded accounts are deactivated**, so the moment a shop has real
|
||||
/// staff the built-in PINs stop working — without this the hardcoded
|
||||
/// logins would survive alongside the real ones for ever; and
|
||||
/// 3. anyone previously imported who is no longer named is deactivated too,
|
||||
/// because a leaver removed in the back office must lose the till.
|
||||
///
|
||||
/// Deactivated, never deleted. Bills carry the cashier's name and shifts are
|
||||
/// settled against it, so a hard delete would orphan a day's takings.
|
||||
///
|
||||
/// Does nothing at all when [members] is empty. That is the common case today —
|
||||
/// most outlets have no staff recorded — and wiping a working till's logins
|
||||
/// because the back office has not been filled in yet would close a shop.
|
||||
extension StaffImport on StaffDao {
|
||||
Future<int> replaceFromBackOffice(List<StaffImportRecord> members) async {
|
||||
if (members.isEmpty) return 0;
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final imported = <String>{};
|
||||
|
||||
for (final member in members) {
|
||||
final pin = member.pin.trim();
|
||||
// A blank or malformed PIN cannot be signed in with. Skipped rather than
|
||||
// written, so the till does not show a name nobody can use.
|
||||
if (pin.length < 4 || int.tryParse(pin) == null) continue;
|
||||
|
||||
final salt = PinHasher.newSalt();
|
||||
imported.add(member.localId);
|
||||
|
||||
await db.insert(
|
||||
Tables.staff,
|
||||
{
|
||||
'id': member.localId,
|
||||
'name': member.name.isEmpty ? 'Staff ${member.localId}' : member.name,
|
||||
'role': member.role.name,
|
||||
'pin_hash': PinHasher.hash(pin, salt),
|
||||
'pin_salt': salt,
|
||||
// Not flagged for change: this PIN was set by the shop in the back
|
||||
// office, so it is already theirs. The flag is for the seeds.
|
||||
'must_change_pin': 0,
|
||||
'is_active': 1,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing usable came back — leave the till exactly as it was rather than
|
||||
// stranding it with no way to sign in.
|
||||
if (imported.isEmpty) return 0;
|
||||
|
||||
final placeholders = List.filled(imported.length, '?').join(',');
|
||||
await db.update(
|
||||
Tables.staff,
|
||||
{'is_active': 0, 'updated_at': now},
|
||||
where: 'id NOT IN ($placeholders)',
|
||||
whereArgs: imported.toList(),
|
||||
);
|
||||
|
||||
return imported.length;
|
||||
}
|
||||
}
|
||||
|
||||
/// One person to import, already mapped onto the till's own role vocabulary.
|
||||
class StaffImportRecord {
|
||||
const StaffImportRecord({
|
||||
required this.localId,
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.pin,
|
||||
});
|
||||
|
||||
final String localId;
|
||||
final String name;
|
||||
final StaffRole role;
|
||||
final String pin;
|
||||
}
|
||||
|
||||
378
lib/data/remote/auth_api.dart
Normal file
378
lib/data/remote/auth_api.dart
Normal file
@@ -0,0 +1,378 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../core/config/api_config.dart';
|
||||
import '../../domain/entities/store_account.dart';
|
||||
|
||||
/// A back office that answered, but not with what was asked for.
|
||||
///
|
||||
/// [isNetworkFault] separates "the server said no" from "there was no server".
|
||||
/// The first is final — a wrong password does not become right on a retry, and
|
||||
/// must never fall through to a local account. The second is the shop's line
|
||||
/// being down, which is the only case an offline path may consider.
|
||||
class ApiException implements Exception {
|
||||
const ApiException(
|
||||
this.message, {
|
||||
this.statusCode,
|
||||
this.isNetworkFault = false,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final bool isNetworkFault;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// One person on the store's staff list, as the back office describes them.
|
||||
///
|
||||
/// ```json
|
||||
/// { "user_id": 1148, "full_name": "Ragul Kannan",
|
||||
/// "role": "Super admin", "pin": "1111", "status": "Active" }
|
||||
/// ```
|
||||
class ApiStaffMember {
|
||||
const ApiStaffMember({
|
||||
required this.userId,
|
||||
required this.fullName,
|
||||
required this.role,
|
||||
this.pin,
|
||||
this.status = 'Active',
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String fullName;
|
||||
|
||||
/// Free text from the back office: `Super admin`, `Admin`, `Cashier`,
|
||||
/// `Manager`. Compared case- and space-insensitively, never matched exactly.
|
||||
final String role;
|
||||
|
||||
/// The till PIN. May be absent, in which case this person exists on the
|
||||
/// staff list but cannot be switched to at the counter.
|
||||
final String? pin;
|
||||
|
||||
final String status;
|
||||
|
||||
bool get isActive => status.trim().toLowerCase() == 'active';
|
||||
|
||||
StaffRole get staffRole => ApiRoles.toStaffRole(role);
|
||||
|
||||
bool get isAdmin => ApiRoles.isAdminRole(role);
|
||||
|
||||
static ApiStaffMember fromJson(Map<String, Object?> json) => ApiStaffMember(
|
||||
userId: _asInt(json['user_id']) ?? 0,
|
||||
fullName: (json['full_name'] as String? ?? '').trim(),
|
||||
role: (json['role'] as String? ?? '').trim(),
|
||||
pin: _nonEmpty(json['pin'] as String?),
|
||||
status: (json['status'] as String? ?? 'Active').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/// An outlet this account can sign into.
|
||||
class ApiLocation {
|
||||
const ApiLocation({
|
||||
required this.locationId,
|
||||
required this.locationName,
|
||||
this.address = '',
|
||||
this.city = '',
|
||||
this.status = 'Active',
|
||||
});
|
||||
|
||||
final int locationId;
|
||||
final String locationName;
|
||||
final String address;
|
||||
final String city;
|
||||
final String status;
|
||||
|
||||
bool get isActive => status.trim().toLowerCase() == 'active';
|
||||
|
||||
static ApiLocation fromJson(Map<String, Object?> json) => ApiLocation(
|
||||
locationId: _asInt(json['location_id']) ?? 0,
|
||||
locationName: (json['location_name'] as String? ?? '').trim(),
|
||||
address: (json['address'] as String? ?? '').trim(),
|
||||
city: (json['city'] as String? ?? '').trim(),
|
||||
status: (json['status'] as String? ?? 'Active').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Everything the back office hands back on a successful sign-in.
|
||||
///
|
||||
/// This is the terminal's whole identity for the rest of the session: the
|
||||
/// bearer token every other call carries, the location id the catalogue and
|
||||
/// order endpoints are scoped to, and the tenant name shown beside the logo.
|
||||
/// Nothing below it is guessed or held as a constant.
|
||||
class LoginSession {
|
||||
const LoginSession({
|
||||
required this.token,
|
||||
required this.userId,
|
||||
required this.email,
|
||||
required this.roleId,
|
||||
required this.tenantId,
|
||||
required this.tenantName,
|
||||
required this.storeId,
|
||||
required this.locationId,
|
||||
required this.locationName,
|
||||
this.fullName = '',
|
||||
this.address = '',
|
||||
this.phone = '',
|
||||
this.expiresAt,
|
||||
this.locations = const [],
|
||||
this.staff = const [],
|
||||
});
|
||||
|
||||
/// Bearer token. Sent as `authorization: Bearer …` on the catalogue pull and
|
||||
/// on every order push — see `SyncConfig.apiKey`, which this fills.
|
||||
final String token;
|
||||
|
||||
final DateTime? expiresAt;
|
||||
|
||||
final int userId;
|
||||
final String fullName;
|
||||
final String email;
|
||||
|
||||
/// The back office's numeric role for this account. Used only when [staff]
|
||||
/// does not name this person — see [isAdmin].
|
||||
final int roleId;
|
||||
|
||||
final int tenantId;
|
||||
final String tenantName;
|
||||
|
||||
/// The back office sends this as a string; kept as one because it is used as
|
||||
/// a path and query value, never arithmetically.
|
||||
final String storeId;
|
||||
|
||||
final int locationId;
|
||||
final String locationName;
|
||||
final String address;
|
||||
final String phone;
|
||||
|
||||
final List<ApiLocation> locations;
|
||||
final List<ApiStaffMember> staff;
|
||||
|
||||
/// What goes beside the logo. The tenant is the business, the location is
|
||||
/// the branch — a single-outlet shop usually sets both to the same thing.
|
||||
String get displayStoreName =>
|
||||
tenantName.isNotEmpty ? tenantName : locationName;
|
||||
|
||||
/// Who signed in, for the profile row and for stamping bills.
|
||||
String get displayUserName {
|
||||
if (fullName.isNotEmpty) return fullName;
|
||||
final me = whoAmI;
|
||||
if (me != null && me.fullName.isNotEmpty) return me.fullName;
|
||||
final at = email.indexOf('@');
|
||||
return at > 0 ? email.substring(0, at) : email;
|
||||
}
|
||||
|
||||
/// This account's own row in [staff], when the back office included one.
|
||||
ApiStaffMember? get whoAmI {
|
||||
for (final s in staff) {
|
||||
if (s.userId == userId) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Which screen the terminal opens on.
|
||||
///
|
||||
/// The staff list is the authority when it names this person, because it
|
||||
/// carries the role as text — `Super admin`, `Cashier` — which is what the
|
||||
/// back office actually manages. [roleId] is the fallback for the case in
|
||||
/// the sample response, where `staff` comes back empty.
|
||||
bool get isAdmin {
|
||||
final me = whoAmI;
|
||||
if (me != null && me.role.isNotEmpty) return me.isAdmin;
|
||||
return ApiRoles.adminRoleIds.contains(roleId);
|
||||
}
|
||||
|
||||
/// The role to file this person under locally.
|
||||
StaffRole get staffRole {
|
||||
final me = whoAmI;
|
||||
if (me != null && me.role.isNotEmpty) return me.staffRole;
|
||||
return isAdmin ? StaffRole.admin : StaffRole.cashier;
|
||||
}
|
||||
|
||||
bool get isExpired =>
|
||||
expiresAt != null && DateTime.now().isAfter(expiresAt!.toLocal());
|
||||
|
||||
/// Reads `details`, not the envelope. [AuthApi.login] has already checked
|
||||
/// `status` and `code` by the time this runs.
|
||||
static LoginSession fromDetails(Map<String, Object?> d) {
|
||||
final locationId = _asInt(d['location_id']) ?? 0;
|
||||
|
||||
return LoginSession(
|
||||
token: (d['token'] as String? ?? '').trim(),
|
||||
expiresAt: DateTime.tryParse(d['expires_at'] as String? ?? ''),
|
||||
userId: _asInt(d['user_id']) ?? 0,
|
||||
fullName: (d['full_name'] as String? ?? '').trim(),
|
||||
email: (d['email'] as String? ?? '').trim(),
|
||||
roleId: _asInt(d['role_id']) ?? 0,
|
||||
tenantId: _asInt(d['tenant_id']) ?? 0,
|
||||
tenantName: (d['tenant_name'] as String? ?? '').trim(),
|
||||
// Falls back to the location id: they are the same number in every
|
||||
// response seen so far, and a blank store id would scope the catalogue
|
||||
// pull to nothing.
|
||||
storeId: _nonEmpty(d['store_id']?.toString()) ?? '$locationId',
|
||||
locationId: locationId,
|
||||
locationName: (d['location_name'] as String? ?? '').trim(),
|
||||
address: (d['address'] as String? ?? '').trim(),
|
||||
phone: (d['phone'] as String? ?? '').trim(),
|
||||
locations: _list(d['locations'], ApiLocation.fromJson),
|
||||
staff: _list(d['staff'], ApiStaffMember.fromJson),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the back office's role names onto what the terminal may open.
|
||||
class ApiRoles {
|
||||
const ApiRoles._();
|
||||
|
||||
/// Numeric roles that open the full shell, for responses whose `staff` array
|
||||
/// is empty. `1` is Super admin.
|
||||
///
|
||||
/// Add to this rather than to the string rules if the back office introduces
|
||||
/// another privileged role id.
|
||||
static const Set<int> adminRoleIds = {1, 2};
|
||||
|
||||
/// Case, spacing and punctuation are all ignored: `Super admin`,
|
||||
/// `super_admin` and `SUPERADMIN` are one role, and the back office is free
|
||||
/// to change how it writes them.
|
||||
static String _normalise(String role) =>
|
||||
role.toLowerCase().replaceAll(RegExp(r'[^a-z]'), '');
|
||||
|
||||
/// True for anything that should land on the admin shell rather than the
|
||||
/// billing screen alone.
|
||||
static bool isAdminRole(String role) {
|
||||
final r = _normalise(role);
|
||||
if (r.isEmpty) return false;
|
||||
// `superadmin` contains `admin`, so one check covers both.
|
||||
return r.contains('admin') || r == 'owner';
|
||||
}
|
||||
|
||||
static StaffRole toStaffRole(String role) {
|
||||
final r = _normalise(role);
|
||||
if (r.contains('admin') || r == 'owner') return StaffRole.admin;
|
||||
if (r.contains('manager') || r.contains('supervisor')) {
|
||||
return StaffRole.manager;
|
||||
}
|
||||
return StaffRole.cashier;
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs a terminal in against the back office.
|
||||
///
|
||||
/// ```
|
||||
/// POST {base}/login
|
||||
/// { "authname": "rmart@gmail.com", "password": "…",
|
||||
/// "device_id": "a5f3c8b9-…", "configid": 1 }
|
||||
/// ```
|
||||
///
|
||||
/// `device_id` is this till's own minted identity — not a fresh uuid per
|
||||
/// attempt — so the back office can recognise a terminal across restarts and
|
||||
/// refuse one it has not registered.
|
||||
class AuthApi {
|
||||
AuthApi({http.Client? client, this.baseUrl = ApiConfig.baseUrl})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
final String baseUrl;
|
||||
|
||||
Future<LoginSession> login({
|
||||
required String authname,
|
||||
required String password,
|
||||
required String deviceId,
|
||||
int configId = ApiConfig.configId,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl${ApiConfig.loginPath}');
|
||||
|
||||
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,
|
||||
'device_id': deviceId,
|
||||
'configid': configId,
|
||||
}),
|
||||
)
|
||||
.timeout(ApiConfig.timeout);
|
||||
} on Object catch (e) {
|
||||
throw ApiException(
|
||||
'Could not reach the back office. Check this terminal’s internet '
|
||||
'connection and try again. ($e)',
|
||||
isNetworkFault: true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> body;
|
||||
try {
|
||||
body = jsonDecode(response.body) as Map<String, Object?>;
|
||||
} on Object {
|
||||
throw ApiException(
|
||||
'The back office answered with something this terminal could not '
|
||||
'read (${response.statusCode}).',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final ok = body['status'] == true && _asInt(body['code']) == 200;
|
||||
final details = body['details'];
|
||||
|
||||
if (!ok || details is! Map<String, Object?>) {
|
||||
// The server's own wording is better than anything invented here: it
|
||||
// knows whether the account is wrong, locked, or on a device the shop
|
||||
// has not registered.
|
||||
throw ApiException(
|
||||
(body['message'] as String?)?.trim().isNotEmpty ?? false
|
||||
? body['message']! as String
|
||||
: 'Sign-in was refused (${response.statusCode}).',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final session = LoginSession.fromDetails(details);
|
||||
|
||||
if (session.token.isEmpty) {
|
||||
throw const ApiException(
|
||||
'The back office signed this account in but returned no token, so '
|
||||
'nothing else could be fetched. Contact support.',
|
||||
);
|
||||
}
|
||||
|
||||
if (session.locationId == 0) {
|
||||
throw const ApiException(
|
||||
'The back office returned no location for this account. A terminal '
|
||||
'has to belong to an outlet before it can pull products.',
|
||||
);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Helpers
|
||||
/// The back office is inconsistent about numbers — `store_id` arrives quoted,
|
||||
/// `location_id` bare — so both shapes are accepted everywhere.
|
||||
int? _asInt(Object? value) => switch (value) {
|
||||
int v => v,
|
||||
num v => v.toInt(),
|
||||
String v => int.tryParse(v.trim()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
String? _nonEmpty(String? value) {
|
||||
final t = value?.trim();
|
||||
return (t == null || t.isEmpty) ? null : t;
|
||||
}
|
||||
|
||||
List<T> _list<T>(Object? raw, T Function(Map<String, Object?>) decode) {
|
||||
if (raw is! List) return const [];
|
||||
return raw.whereType<Map<String, Object?>>().map(decode).toList();
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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<PosSession> 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 = <String, Object?>{
|
||||
'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<String, Object?> decoded;
|
||||
try {
|
||||
decoded = jsonDecode(response.body) as Map<String, Object?>;
|
||||
} 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<String, Object?>) {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user