diff --git a/lib/app/providers.dart b/lib/app/providers.dart index c04e1c5..d28b81b 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -17,7 +17,9 @@ import '../data/remote/simulated_order_transport.dart'; import '../data/repositories/store_repository_impl.dart'; import '../data/repositories/sync_repository_impl.dart'; import '../data/repositories/transaction_repository_impl.dart'; +import '../data/local/session_store.dart'; import '../data/local/terminal_identity.dart'; +import '../data/remote/pos_auth_api.dart'; import '../data/sync/sync_engine.dart'; import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/product_repository.dart'; @@ -161,10 +163,28 @@ final storeRepositoryProvider = Provider( (ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)), ); +/// Signs a terminal in against the back office. +/// +/// Points at the same base URL the uplinks use, so re-pointing a terminal in +/// Settings moves its sign-in with it rather than leaving it authenticating +/// against the endpoint it used to belong to. +final posAuthApiProvider = Provider((ref) { + final api = PosAuthApi(baseUrl: ref.watch(syncConfigProvider).httpBaseUrl); + ref.onDispose(api.dispose); + return api; +}); + +/// Where the signed session survives a restart. +final sessionStoreProvider = Provider((ref) => SessionStore()); + /// The outlet, refreshed whenever staff or details change. +/// +/// The email is the signed-in account's, not a constant. It used to be a +/// `DemoCredentials.email` compiled into the build — the same address on every +/// install, which is what made the store login decorative. final storeAccountProvider = FutureProvider( (ref) => ref.watch(storeRepositoryProvider).load( - email: DemoCredentials.email, + email: ref.watch(authControllerProvider.notifier).session?.email ?? '', ), ); diff --git a/lib/core/config/sync_config.dart b/lib/core/config/sync_config.dart index fb13417..500a736 100644 --- a/lib/core/config/sync_config.dart +++ b/lib/core/config/sync_config.dart @@ -35,6 +35,7 @@ class SyncConfig { this.password, this.httpBaseUrl = '', this.apiKey, + this.sessionToken, this.ackTimeout = const Duration(seconds: 20), this.batchSize = 50, }); @@ -52,8 +53,38 @@ class SyncConfig { final String? password; final String httpBaseUrl; + + /// A static key shared by every terminal at a deployment, if one is set. + /// + /// Predates sign-in and says nothing about *who* is at the till, so it cannot + /// scope a request to an outlet. Kept for deployments that put one in front + /// of the endpoint. final String? apiKey; + /// The signed session from `POST /login`, held for the trading day. + /// + /// Distinct from [apiKey] because the two answer different questions. The key + /// says "this request came from our fleet"; the session says "this request + /// came from Selvapuram, signed in as Ragul, and may touch that outlet and no + /// other". Only the second can stop a till reaching another tenant's books, + /// which is why it takes precedence when both are present. + final String? sessionToken; + + /// What goes in the Authorization header. + /// + /// One accessor rather than the same `??` repeated at each call site, because + /// the request that forgot it would be the one silently sending no + /// credentials at all. + String? get bearerToken { + final session = sessionToken?.trim(); + if (session != null && session.isNotEmpty) return session; + + final key = apiKey?.trim(); + if (key != null && key.isNotEmpty) return key; + + return null; + } + /// How long to wait for the back office to confirm a batch before treating /// the outcome as unknown and leaving every row pending. /// @@ -148,6 +179,7 @@ class SyncConfig { String? password, String? httpBaseUrl, String? apiKey, + String? sessionToken, Duration? ackTimeout, int? batchSize, }) => @@ -162,6 +194,7 @@ class SyncConfig { password: password ?? this.password, httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl, apiKey: apiKey ?? this.apiKey, + sessionToken: sessionToken ?? this.sessionToken, ackTimeout: ackTimeout ?? this.ackTimeout, batchSize: batchSize ?? this.batchSize, ); diff --git a/lib/data/local/session_store.dart b/lib/data/local/session_store.dart new file mode 100644 index 0000000..9013ea2 --- /dev/null +++ b/lib/data/local/session_store.dart @@ -0,0 +1,81 @@ +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 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); + 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 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 clear() async { + try { + await _secure.delete(key: _key); + } on Object catch (e) { + debugPrint('Could not clear the session: $e'); + } + } +} diff --git a/lib/data/local/staff_dao.dart b/lib/data/local/staff_dao.dart index 414d9d6..b5cc873 100644 --- a/lib/data/local/staff_dao.dart +++ b/lib/data/local/staff_dao.dart @@ -25,6 +25,10 @@ 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. @@ -277,3 +281,93 @@ 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 replaceFromBackOffice(List members) async { + if (members.isEmpty) return 0; + + final now = DateTime.now().millisecondsSinceEpoch; + final imported = {}; + + 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; +} diff --git a/lib/data/remote/pos_auth_api.dart b/lib/data/remote/pos_auth_api.dart new file mode 100644 index 0000000..fc40769 --- /dev/null +++ b/lib/data/remote/pos_auth_api.dart @@ -0,0 +1,149 @@ +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(); +} diff --git a/lib/domain/entities/pos_session.dart b/lib/domain/entities/pos_session.dart new file mode 100644 index 0000000..21b1896 --- /dev/null +++ b/lib/domain/entities/pos_session.dart @@ -0,0 +1,281 @@ +/// What the back office answers a sign-in with. +/// +/// Replaces the arrangement where a till held a store id typed into Settings +/// and a password compiled into the app. That made the store id a *claim*: any +/// terminal could name any outlet and be believed, so one leaked build reached +/// every tenant on the platform. +/// +/// Now the outlet arrives *from* the back office as a consequence of who signed +/// in, sealed inside a signed token the terminal cannot edit. The till stops +/// deciding which shop it belongs to and starts being told. +class PosSession { + const PosSession({ + required this.token, + required this.expiresAt, + required this.userId, + required this.fullName, + required this.roleId, + required this.tenantId, + required this.tenantName, + required this.storeId, + required this.locationId, + required this.locationName, + this.email = '', + this.role = '', + this.canManageStaff = false, + this.gstin = '', + this.address = '', + this.phone = '', + this.outlets = const [], + this.staff = const [], + }); + + /// The bearer token, sent on every request from here on. + /// + /// Opaque on purpose. The terminal must not parse it, reason about it, or + /// trust anything it appears to say — its only correct use is to hand it back + /// and let the server decide what it means. + final String token; + + final DateTime expiresAt; + + final int userId; + final String fullName; + final String email; + final int roleId; + + /// The back office's name for [roleId] — "Supervisor", "Cashier", "Admin". + /// + /// For display. Never branch on it: `app_roles` holds six rows for four + /// distinct roles, and a great many accounts carry a `roleid` that is not in + /// the table at all and come back blank. [canManageStaff] is the flag to + /// read. + final String role; + + /// Whether this account runs the terminal or only bills on it. + /// + /// The one permission the till acts on, and it is answered by the back office + /// rather than worked out here. A supervisor gets the full shell; a cashier + /// gets the billing screen. Deciding it locally would mean shipping a copy of + /// the role table in the app and keeping the two in step for ever. + /// + /// Defaults to false, which is the least-privileged answer. That matters on + /// the restore path: a session saved by a build that predates this field + /// comes back as a cashier rather than silently as an admin. + final bool canManageStaff; + + final int tenantId; + final String tenantName; + + /// The outlet this terminal bills for, as a string because that is the shape + /// the sync configuration and every uplink already use. + final String storeId; + final int locationId; + final String locationName; + + /// Printed on the invoice, so a legal requirement rather than decoration. + /// Arriving with the session means a shop that corrects its GSTIN in the back + /// office sees it on the next receipt instead of at the next rebuild. + final String gstin; + final String address; + final String phone; + + /// Every outlet this account may open a till at. + /// + /// A single-shop user gets a list of one, so the sign-in flow has no special + /// case: it offers a choice when there is one and skips it when there is not. + final List outlets; + + bool get hasChoiceOfOutlet => outlets.length > 1; + + /// The people the back office says may ring a bill here. + /// + /// Very often empty. Only 116 of 596 accounts on the platform have a PIN set, + /// and most outlets — including the one this terminal ships pointed at — have + /// none at all. A till must treat that as an unfinished setup rather than as + /// a failure, which is why the seeded accounts still exist as a last resort. + final List staff; + + /// Whether the session is still worth sending. + /// + /// Checked against the terminal's own clock, which is the only one available + /// offline. A till whose clock is wrong will re-authenticate unnecessarily — + /// annoying, and much better than billing a whole day against a session the + /// server has already stopped accepting. + bool isValidAt(DateTime now) => now.isBefore(expiresAt); + + PosSession copyWith({ + String? storeId, + int? locationId, + String? locationName, + String? address, + }) => + PosSession( + token: token, + expiresAt: expiresAt, + userId: userId, + fullName: fullName, + email: email, + roleId: roleId, + role: role, + canManageStaff: canManageStaff, + tenantId: tenantId, + tenantName: tenantName, + storeId: storeId ?? this.storeId, + locationId: locationId ?? this.locationId, + locationName: locationName ?? this.locationName, + gstin: gstin, + address: address ?? this.address, + phone: phone, + outlets: outlets, + staff: staff, + ); + + factory PosSession.fromJson(Map json) { + final outlets = (json['locations'] as List? ?? const []) + .whereType>() + .map(PosOutlet.fromJson) + .toList(); + + return PosSession( + token: (json['token'] as String?) ?? '', + // A session with no readable expiry is treated as already finished rather + // than as never finishing. Guessing "valid" here would keep a till + // sending a token the server stopped honouring hours ago. + expiresAt: DateTime.tryParse((json['expires_at'] as String?) ?? '') + ?.toLocal() ?? + DateTime.fromMillisecondsSinceEpoch(0), + userId: _int(json['user_id']), + fullName: (json['full_name'] as String?)?.trim() ?? '', + email: (json['email'] as String?) ?? '', + roleId: _int(json['role_id']), + role: ((json['role'] as String?) ?? '').trim(), + canManageStaff: json['can_manage_staff'] == true, + tenantId: _int(json['tenant_id']), + tenantName: (json['tenant_name'] as String?) ?? '', + storeId: (json['store_id'] as String?) ?? '${_int(json['location_id'])}', + locationId: _int(json['location_id']), + locationName: (json['location_name'] as String?) ?? '', + gstin: (json['gstin'] as String?) ?? '', + address: (json['address'] as String?) ?? '', + phone: (json['phone'] as String?) ?? '', + outlets: outlets, + staff: (json['staff'] as List? ?? const []) + .whereType>() + .map(PosStaffMember.fromJson) + .where((m) => m.pin.isNotEmpty) + .toList(), + ); + } + + Map toJson() => { + 'token': token, + 'expires_at': expiresAt.toIso8601String(), + 'user_id': userId, + 'full_name': fullName, + 'email': email, + 'role_id': roleId, + 'role': role, + 'can_manage_staff': canManageStaff, + 'tenant_id': tenantId, + 'tenant_name': tenantName, + 'store_id': storeId, + 'location_id': locationId, + 'location_name': locationName, + 'gstin': gstin, + 'address': address, + 'phone': phone, + 'locations': outlets.map((o) => o.toJson()).toList(), + 'staff': staff.map((m) => m.toJson()).toList(), + }; +} + +/// One outlet a signed-in account may bill for. +class PosOutlet { + const PosOutlet({ + required this.locationId, + required this.locationName, + this.address = '', + this.city = '', + }); + + final int locationId; + final String locationName; + final String address; + final String city; + + String get storeId => '$locationId'; + + factory PosOutlet.fromJson(Map json) => PosOutlet( + locationId: _int(json['location_id']), + locationName: (json['location_name'] as String?) ?? '', + address: (json['address'] as String?) ?? '', + city: (json['city'] as String?) ?? '', + ); + + Map toJson() => { + 'location_id': locationId, + 'location_name': locationName, + 'address': address, + 'city': city, + }; +} + +/// Reads an id that may arrive as a number or as a string. +/// +/// The backend sends `location_id` as an int and `store_id` as a string for the +/// same value, and a till that accepted only one shape would silently read zero +/// for the other — which looks like "no outlet" rather than like a bug. +int _int(Object? value) => switch (value) { + final int v => v, + final num v => v.toInt(), + final String v => int.tryParse(v.trim()) ?? 0, + _ => 0, + }; + +/// One person the back office says may ring a bill at this outlet. +/// +/// The PIN arrives in the clear over TLS and is hashed before it touches disk — +/// see [PosSession] and the backend's `PosStaffMember` for why hashing it +/// server-side would have bought the appearance of strength and not the +/// substance. A four-digit PIN is brute-forceable in microseconds regardless; +/// what it is, is *shift attribution* — which of the people already inside a +/// shop gets credited with a sale. The security boundary is the session token. +class PosStaffMember { + const PosStaffMember({ + required this.userId, + required this.fullName, + required this.pin, + this.role = '', + }); + + final int userId; + final String fullName; + final String pin; + + /// The back office's own role name — "Admin", "Manager", "Operations". Blank + /// for the many accounts whose `roleid` is not in `app_roles` at all. + final String role; + + /// A stable local id for a row that came from the back office. + /// + /// Prefixed so an imported account can be told apart from one seeded on this + /// device. That distinction is what lets a sync retire the seeded logins + /// without touching anything a shop created itself. + String get localId => 'boffice-$userId'; + + factory PosStaffMember.fromJson(Map json) => + PosStaffMember( + userId: _int(json['user_id']), + fullName: ((json['full_name'] as String?) ?? '').trim(), + pin: ((json['pin'] as String?) ?? '').trim(), + role: ((json['role'] as String?) ?? '').trim(), + ); + + Map toJson() => { + 'user_id': userId, + 'full_name': fullName, + 'pin': pin, + 'role': role, + }; +} diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index 238eb19..11a1f6d 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -1,6 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; +import '../../../data/local/staff_dao.dart'; +import '../../../data/remote/pos_auth_api.dart'; +import '../../../domain/entities/pos_session.dart'; import '../../../domain/entities/store_account.dart'; /// Sign-in state for the terminal. @@ -28,8 +31,8 @@ class Authenticated extends AuthState { final StoreAccount store; final StaffUser user; - /// Which credential opened this session. The authority on what the terminal - /// is allowed to show — not [user], which can be swapped at the till. + /// What this session may open. The authority on what the terminal shows — + /// not [user], which can be swapped at the till without re-authenticating. final TerminalLogin login; StaffRole get role => login.role; @@ -44,80 +47,92 @@ class AuthFailure extends AuthState { final String message; } -/// The two ways into this terminal. +/// What a signed-in account may do with this terminal. /// -/// Store-level credentials, not a person's: they are replaced wholesale when -/// the terminal is registered against a real back office. Staff PINs — the -/// credential that actually opens a till drawer — are not here. They live -/// hashed in the database. -/// -/// The split is what the two roles are *for*, not decoration: +/// Two shapes, because the terminal only ever behaves in two ways, and the +/// split is what the roles are *for* rather than decoration: /// /// * [admin] runs the whole shell and is the only login that can pull the /// catalogue. Signing out leaves the products on the terminal. /// * [cashier] gets the billing screen and nothing else, and signing out /// takes the catalogue with it. +/// +/// This used to carry an email and a password per entry, and the terminal +/// decided which role you were by comparing what you typed against those +/// constants. That made the role a property of the *build* — every install +/// shared two logins, and a shop could not add a third person or revoke the +/// two it had without shipping a new APK. +/// +/// The role now arrives from the back office as a property of the *account*. +/// [forSession] is the only way to construct one, so there is no path left +/// where the terminal grants itself a permission the server did not send. enum TerminalLogin { admin( - label: 'Admin', - email: 'admin@nearle.in', - password: 'nearle123', + label: 'Supervisor', role: StaffRole.admin, - blurb: 'Full shell — import products, promos, settings.', + blurb: 'Full shell — import products, promos, staff, settings.', ), cashier( label: 'Cashier', - email: 'cashier@nearle.in', - password: 'cashier123', role: StaffRole.cashier, - blurb: 'Billing only, on the products the admin imported.', + blurb: 'Billing only, on the products the supervisor imported.', ); const TerminalLogin({ required this.label, - required this.email, - required this.password, required this.role, required this.blurb, }); final String label; - final String email; - final String password; final StaffRole role; final String blurb; - /// The catalogue is pulled once by an admin and billed against by whoever is - /// on the counter, so only the cashier's sign-out drops it. An admin closing - /// the shell is a handover, not the end of the day. + /// The catalogue is pulled once by a supervisor and billed against by + /// whoever is on the counter, so only the cashier's sign-out drops it. A + /// supervisor closing the shell is a handover, not the end of the day. bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier; - static TerminalLogin? byEmail(String email) { - final normalised = email.trim().toLowerCase(); - for (final login in TerminalLogin.values) { - if (login.email == normalised) return login; - } - return null; - } + /// Which shell the back office says this account gets. + /// + /// Reads `can_manage_staff` rather than the role name or id. The name is + /// free text and often blank, and `app_roles` holds six rows for four + /// distinct roles with a great many accounts carrying a `roleid` that is not + /// in the table at all — so matching on either here would mean keeping a + /// copy of the role table in the app and keeping the two in step for ever. + /// One boolean, decided by the server, cannot drift. + static TerminalLogin forSession(PosSession session) => + session.canManageStaff ? TerminalLogin.admin : TerminalLogin.cashier; } -/// Kept for the store record, which is keyed on the outlet's own address. -class DemoCredentials { - const DemoCredentials._(); - - static const String email = 'admin@nearle.in'; - static const String password = 'nearle123'; - - static const String cashierEmail = 'cashier@nearle.in'; - static const String cashierPassword = 'cashier123'; -} - -/// Validates store credentials and holds the signed-in session. +/// Signs the terminal in against the back office and holds the session. +/// +/// This used to compare against constants compiled into the app, with a 600ms +/// delay standing in for a network call that was never made. Two things were +/// wrong with that, and the second was the serious one: +/// +/// 1. every install of a build shared one password, and changing it meant a +/// rebuild; and +/// 2. because nothing was checked with the back office, the *outlet* could not +/// come from the sign-in. It came from a store id typed into Settings — so +/// a till named its own shop and was believed, and one number changed on +/// one screen moved a terminal into another tenant's books. +/// +/// Now a person signs in with their own back-office account, and both the +/// outlet and the role arrive as a consequence: sealed in a signed token, +/// checked server-side on every request, and not editable from this device. class AuthController extends StateNotifier { AuthController(this._ref) : super(const Unauthenticated()); final Ref _ref; + /// The back office's answer to the last sign-in, if there is one. + /// + /// Held so the outlet picker can offer a proprietor their other shops without + /// asking for the password a second time. + PosSession? _session; + PosSession? get session => _session; + /// Whether signing out right now would wipe the products off this terminal. /// /// Read *before* [signOut] by anything that needs to warn the operator, since @@ -127,27 +142,121 @@ class AuthController extends StateNotifier { return current is Authenticated && current.login.clearsCatalogueOnSignOut; } + /// Restores a session saved on a previous run. + /// + /// Called at start-up so a till that was rebooted mid-shift comes back + /// trading rather than showing a login screen to a queue of customers. + /// Returns false when there is nothing usable, which includes an expired + /// session — [SessionStore] treats those as absent. + Future restore() async { + final saved = await _ref.read(sessionStoreProvider).read(); + if (saved == null) return false; + + await _adopt(saved); + return state is Authenticated; + } + Future signIn({ required String email, required String password, + int? locationId, }) async { state = const Authenticating(); - // Stand-in for the network round trip. - await Future.delayed(const Duration(milliseconds: 600)); + final terminal = _ref.read(terminalIdentityProvider); - final login = TerminalLogin.byEmail(email); - - if (login == null) { - state = const AuthFailure('No account is registered against that email.'); + final PosSession session; + try { + session = await _ref.read(posAuthApiProvider).login( + authname: email, + password: password, + terminalId: terminal.code, + deviceId: terminal.deviceId, + locationId: locationId, + ); + } on PosAuthException catch (e) { + state = AuthFailure(e.message); + return false; + } on Object { + state = const AuthFailure( + 'Sign-in failed for an unexpected reason. Please try again.', + ); return false; } - if (password != login.password) { - state = const AuthFailure('Incorrect password. Please try again.'); - return false; - } + await _ref.read(sessionStoreProvider).write(session); + await _adopt(session); + return state is Authenticated; + } + + /// Moves this terminal to another of the signed-in account's outlets. + /// + /// A fresh sign-in rather than a local switch, because the outlet is inside + /// the signed token: the back office has to issue a new one, and re-checking + /// entitlement at that moment is the point. Requires the password again, + /// which is correct — moving a till between shops changes whose books it + /// writes to. + Future switchOutlet({ + required String password, + required int locationId, + }) async { + final current = _session; + if (current == null) return false; + + return signIn( + email: current.email.isNotEmpty ? current.email : current.fullName, + password: password, + locationId: locationId, + ); + } + + /// Adopts a session: points the terminal at its outlet, then opens it. + /// + /// Order matters. The store id and token are written *before* the catalogue + /// or any uplink can run, so a terminal can never spend even one request + /// pointed at the outlet it had yesterday while claiming to be signed in as + /// today's. + Future _adopt(PosSession session) async { + _session = session; + + await _ref.read(localStoreProvider).identityStore.rename( + storeId: session.storeId, + ); + _ref.invalidate(terminalIdentityProvider); + + _ref.read(syncConfigProvider.notifier).state = + _ref.read(syncConfigProvider).copyWith( + storeId: session.storeId, + sessionToken: session.token, + ); + + // Store details for the receipt come from the back office now, not from + // constants compiled into the build. A GSTIN is a legal requirement on a + // tax invoice; it should not need a rebuild to correct. + await _ref.read(storeRepositoryProvider).save( + name: session.locationName.isNotEmpty + ? session.locationName + : session.tenantName, + address: session.address, + gstin: session.gstin, + phone: session.phone, + ); + + // Who may ring a bill here, per the back office. + // + // This is what retires the seeded logins. The till ships with three names + // and three PINs compiled into it — the same three on every install — and + // they exist only so a shop whose back office has no staff recorded can + // still trade on day one. The moment real staff arrive they are + // deactivated, which is the whole point of importing rather than merging. + // + // Empty is the common case rather than an error: most outlets have nobody + // recorded, including the one this build ships pointed at. The import + // no-ops, the seeds survive, and the shop keeps selling. + await _importStaff(session); + + _ref.invalidate(storeAccountProvider); final store = await _ref.read(storeAccountProvider.future); final staff = store.staff; @@ -155,22 +264,74 @@ class AuthController extends StateNotifier { state = const AuthFailure( 'This terminal has no staff accounts. Reinstall to seed them.', ); - return false; + return; } - // Whoever on this terminal matches the role that just signed in. Falls - // back rather than failing: the session's permissions come from [login], - // so a shop with no cashier row still gets a usable till — the bills are - // just stamped with the account that is there. - final opener = staff.firstWhere( - (s) => s.role == login.role, - orElse: () => staff.first, + state = Authenticated( + store: store, + user: _opener(staff, session), + login: TerminalLogin.forSession(session), ); - - state = Authenticated(store: store, user: opener, login: login); - return true; } + /// Who the terminal attributes bills to the moment it opens. + /// + /// The person who just signed in, if the import wrote them — matched on the + /// back office id rather than the name, which is neither unique nor stable. + /// Falls back to anyone rather than failing: the session's permissions come + /// from the token, so a shop whose staff list is empty or unsynced still gets + /// a usable till, and the bills are simply stamped with the account that is + /// there until someone switches with their PIN. + StaffUser _opener(List staff, PosSession session) { + final mine = 'boffice-${session.userId}'; + for (final s in staff) { + if (s.id == mine) return s; + } + + final wanted = TerminalLogin.forSession(session).role; + return staff.firstWhere((s) => s.role == wanted, orElse: () => staff.first); + } + + /// Writes the back office's staff over this terminal's. + /// + /// Failures are swallowed. A shop must be able to open its till even when the + /// staff import fails — the seeded or previously-synced accounts are still + /// there, and refusing the sign-in would trade a working counter for a + /// tidier database. + Future _importStaff(PosSession session) async { + if (session.staff.isEmpty) return; + + try { + await _ref.read(localStoreProvider).staff.replaceFromBackOffice([ + for (final member in session.staff) + StaffImportRecord( + localId: member.localId, + name: member.fullName, + role: _roleFor(member.role), + pin: member.pin, + ), + ]); + } on Object { + // Deliberately silent — see above. + } + } + + /// Maps the back office's role names onto the till's three. + /// + /// `app_roles` holds six rows for four distinct roles — Admin and Manager are + /// each in there twice — and most accounts carry a `roleid` that is not in + /// the table at all. So this matches on the name and falls back to the least + /// privileged answer: an unrecognised role must not silently become an admin. + /// + /// Supervisor is the role a shop actually hands out; it sits with Admin + /// because a supervisor *is* the till's administrator. + StaffRole _roleFor(String backOfficeRole) => + switch (backOfficeRole.trim().toLowerCase()) { + 'super admin' || 'admin' || 'supervisor' => StaffRole.admin, + 'manager' || 'operations' => StaffRole.manager, + _ => StaffRole.cashier, + }; + /// Switches the active operator, checking their PIN. /// /// Every bill is stamped with whoever is active, so this is the boundary that @@ -178,6 +339,11 @@ class AuthController extends StateNotifier { /// list. It changes who the bill names, never what the session may open: /// [Authenticated.login] is untouched, so a cashier terminal stays a cashier /// terminal. + /// + /// That is deliberate. A PIN is four digits typed at an unattended counter; + /// it is shift attribution, not a privilege boundary. Escalating to the full + /// shell takes a real sign-in, because that is the only thing the back office + /// sees and signs. Future switchUser(String pin) async { final current = state; if (current is! Authenticated) return false; @@ -214,19 +380,29 @@ class AuthController extends StateNotifier { /// Ends the session, and — for a cashier only — the catalogue with it. /// - /// Every way out of a cashier session clears the products: ending a shift, - /// and a temporary logout alike. There is no exception for stepping away for - /// ten minutes, because the terminal is left unattended either way and the - /// next session should bill against what the back office answers with rather - /// than a catalogue carried over. + /// The token always goes, from the keystore and from the live configuration + /// both. Leaving it in place would let a signed-out terminal keep uploading + /// as the shop that signed in this morning, so that half is a security + /// matter and takes no exception. /// - /// An admin signing out is the opposite case. They have just pulled the - /// products *so that* a cashier can pick the terminal up, so dropping the - /// table here would make the import pointless. + /// The catalogue is a workflow matter and does take one. Every way out of a + /// cashier session clears the products — ending a shift and a temporary + /// logout alike — because the terminal is left unattended either way and the + /// next session should bill against what the back office answers with rather + /// than a catalogue carried over. A supervisor signing out is the opposite + /// case: they have just pulled the products *so that* a cashier can pick the + /// terminal up, so dropping the table here would make the import pointless. Future signOut() async { if (clearsCatalogueOnSignOut) { await _ref.read(localStoreProvider).clearCatalogue(); } + + await _ref.read(sessionStoreProvider).clear(); + + _ref.read(syncConfigProvider.notifier).state = + _ref.read(syncConfigProvider).copyWith(sessionToken: ''); + + _session = null; state = const Unauthenticated(); } diff --git a/lib/presentation/auth/screens/login_screen.dart b/lib/presentation/auth/screens/login_screen.dart index d069540..de5816c 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,7 +8,6 @@ import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/validators.dart'; -import '../../../core/widgets/brand_mark.dart'; import '../../../core/widgets/primary_button.dart'; import '../providers/auth_controller.dart'; @@ -32,27 +29,15 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); - /// Which of the two default accounts the tabs are pointing at. Only a - /// convenience for filling the fields — [AuthController.signIn] decides the - /// role from the email that is actually submitted, so typing a different - /// address over the top still signs in as that account. - TerminalLogin _login = TerminalLogin.admin; - - late final _email = TextEditingController(text: _login.email); - late final _password = TextEditingController(text: _login.password); + // Both fields start empty. There is nothing to prefill: a person signs in + // with their own back-office account, and which shell they get is a property + // of that account rather than a tab they picked before typing. + final _email = TextEditingController(); + final _password = TextEditingController(); bool _obscure = true; bool _rememberTerminal = true; - void _selectLogin(TerminalLogin login) { - setState(() { - _login = login; - _email.text = login.email; - _password.text = login.password; - }); - ref.read(authControllerProvider.notifier).clearError(); - } - @override void dispose() { _email.dispose(); @@ -124,10 +109,8 @@ class _LoginScreenState extends ConsumerState { password: _password, obscure: _obscure, rememberTerminal: _rememberTerminal, - login: _login, busy: busy, failure: auth is AuthFailure ? auth.message : null, - onSelectLogin: _selectLogin, onToggleObscure: () => setState(() => _obscure = !_obscure), onToggleRemember: (v) => @@ -203,10 +186,8 @@ class _Card extends StatelessWidget { required this.password, required this.obscure, required this.rememberTerminal, - required this.login, required this.busy, required this.failure, - required this.onSelectLogin, required this.onToggleObscure, required this.onToggleRemember, required this.onSubmit, @@ -217,10 +198,8 @@ class _Card extends StatelessWidget { final TextEditingController password; final bool obscure; final bool rememberTerminal; - final TerminalLogin login; final bool busy; final String? failure; - final ValueChanged onSelectLogin; final VoidCallback onToggleObscure; final ValueChanged onToggleRemember; final VoidCallback onSubmit; @@ -268,22 +247,6 @@ class _Card extends StatelessWidget { ), const SizedBox(height: AppSpacing.xl), - _RoleSwitch( - selected: login, - enabled: !busy, - onSelect: onSelectLogin, - ), - const SizedBox(height: AppSpacing.sm), - Text( - login.blurb, - style: const TextStyle( - fontSize: 12.5, - color: AppColors.textTertiary, - height: 1.45, - ), - ), - const SizedBox(height: AppSpacing.xl), - const _Label('Store email'), TextFormField( controller: email, @@ -403,12 +366,6 @@ class _Card extends StatelessWidget { busy: busy, onPressed: onSubmit, ), - - const SizedBox(height: AppSpacing.lg), - _DemoHint( - login: login, - onFill: busy ? null : () => onSelectLogin(login), - ), ], ), ), @@ -436,168 +393,3 @@ class _Label extends StatelessWidget { ); } } - -/// Which of the two default accounts is being signed into. -/// -/// The roles are not cosmetic — they decide whether the terminal opens the -/// full shell or the billing screen alone, and whether signing out leaves the -/// products behind — so the choice is made before the credentials rather than -/// inferred from them afterwards. -class _RoleSwitch extends StatelessWidget { - const _RoleSwitch({ - required this.selected, - required this.enabled, - required this.onSelect, - }); - - final TerminalLogin selected; - final bool enabled; - final ValueChanged onSelect; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: AppRadius.brSm, - border: Border.all(color: AppColors.border), - ), - child: Row( - children: [ - for (final login in TerminalLogin.values) - Expanded( - child: _RoleTab( - login: login, - selected: login == selected, - enabled: enabled, - onTap: () => onSelect(login), - ), - ), - ], - ), - ); - } -} - -class _RoleTab extends StatelessWidget { - const _RoleTab({ - required this.login, - required this.selected, - required this.enabled, - required this.onTap, - }); - - final TerminalLogin login; - final bool selected; - final bool enabled; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final icon = login == TerminalLogin.admin - ? Icons.admin_panel_settings_outlined - : Icons.point_of_sale_rounded; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: enabled ? onTap : null, - borderRadius: AppRadius.brXs, - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected ? AppColors.surface : Colors.transparent, - borderRadius: AppRadius.brXs, - border: Border.all( - color: selected ? AppColors.primaryBorder : Colors.transparent, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 17, - color: selected ? AppColors.primary : AppColors.textSecondary, - ), - const SizedBox(width: AppSpacing.sm), - Flexible( - child: Text( - login.label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13.5, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - color: - selected ? AppColors.primary : AppColors.textSecondary, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class _DemoHint extends StatelessWidget { - const _DemoHint({required this.login, this.onFill}); - - final TerminalLogin login; - final VoidCallback? onFill; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.primarySurface, - borderRadius: AppRadius.brSm, - border: Border.all(color: AppColors.primaryBorder), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.info_outline_rounded, - size: 17, color: AppColors.primary,), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Default ${login.label.toLowerCase()} account', - style: const TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: AppColors.primary, - ), - ), - const SizedBox(height: 2), - SelectableText( - '${login.email} \u00b7 ${login.password}', - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - TextButton( - onPressed: onFill, - style: TextButton.styleFrom( - minimumSize: const Size(0, 32), - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), - ), - child: const Text('Fill', style: TextStyle(fontSize: 12.5)), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/presentation/customer/widgets/customer_capture_sheet.dart b/lib/presentation/customer/widgets/customer_capture_sheet.dart index dc15051..a0eb1aa 100644 --- a/lib/presentation/customer/widgets/customer_capture_sheet.dart +++ b/lib/presentation/customer/widgets/customer_capture_sheet.dart @@ -93,8 +93,10 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> { /// step. Future _save() async { if (!_complete) { - setState(() => _error = 'Enter all ' - '${AppConstants.mobileNumberLength} digits of the mobile number.'); + setState( + () => _error = 'Enter all ' + '${AppConstants.mobileNumberLength} digits of the mobile number.', + ); return; } diff --git a/lib/presentation/pos/screens/pos_view.dart b/lib/presentation/pos/screens/pos_view.dart index a3b132b..2a82f38 100644 --- a/lib/presentation/pos/screens/pos_view.dart +++ b/lib/presentation/pos/screens/pos_view.dart @@ -9,7 +9,6 @@ import '../../auth/providers/auth_controller.dart'; import '../../sync/providers/sync_controller.dart'; import '../providers/navigation_provider.dart'; import '../widgets/category_chips.dart'; -import '../widgets/customer_bar.dart'; import '../widgets/product_grid.dart'; import '../widgets/scan_toast.dart'; import '../widgets/search_field.dart'; diff --git a/lib/presentation/shift/screens/end_shift_screen.dart b/lib/presentation/shift/screens/end_shift_screen.dart index a996bc7..1428b27 100644 --- a/lib/presentation/shift/screens/end_shift_screen.dart +++ b/lib/presentation/shift/screens/end_shift_screen.dart @@ -11,7 +11,6 @@ import '../../../core/utils/formatters.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../domain/entities/shift_report.dart'; import '../../../domain/entities/transaction.dart'; -import '../../../domain/repositories/sync_repository.dart'; import '../../auth/providers/auth_controller.dart'; import '../../payment/screens/payment_screen.dart' show methodIcon; import '../../pos/providers/cart_controller.dart'; diff --git a/lib/presentation/sync/providers/sync_controller.dart b/lib/presentation/sync/providers/sync_controller.dart index adcd90f..4a6de6b 100644 --- a/lib/presentation/sync/providers/sync_controller.dart +++ b/lib/presentation/sync/providers/sync_controller.dart @@ -222,6 +222,18 @@ final syncBootstrapProvider = FutureProvider((ref) async { await store.syncConfig.load(ref.read(syncConfigProvider)); } + // Bring back the session this terminal was signed in under. + // + // Runs before the engine starts, and that ordering is load-bearing: the + // session carries both the bearer token and the outlet, so a drain that began + // first would upload the day's bills unauthenticated — and, once the backend + // is enforcing, have them refused. + // + // A till signs in when a shop opens and trades all day. Without this a reboot + // mid-shift would put a login screen in front of a queue of customers, which + // is a worse outage than the one it protects against. + await ref.read(authControllerProvider.notifier).restore(); + await ref.read(connectivityServiceProvider).start(); final engine = ref.read(syncEngineProvider); diff --git a/lib/presentation/sync/widgets/sign_out_dialog.dart b/lib/presentation/sync/widgets/sign_out_dialog.dart index 92b6ba3..db89535 100644 --- a/lib/presentation/sync/widgets/sign_out_dialog.dart +++ b/lib/presentation/sync/widgets/sign_out_dialog.dart @@ -95,7 +95,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> { if (outcome.isSuccess) { await Future.delayed(const Duration(milliseconds: 700)); - if (mounted) _finish(); + if (mounted) await _finish(); } } diff --git a/test/widget/admin_dialogs_test.dart b/test/widget/admin_dialogs_test.dart index 031a478..c103fc6 100644 --- a/test/widget/admin_dialogs_test.dart +++ b/test/widget/admin_dialogs_test.dart @@ -181,7 +181,17 @@ void main() { /// Holds a fixed session so a test can choose who is signed in. class _StubAuth extends AuthController { _StubAuth(StoreAccount store, StaffUser user) : super(_throwingRef) { - state = Authenticated(store: store, user: user); + // The shell a session opens is decided by the back office, not by the + // person at the counter — so a stub has to state it too. Taken from the + // user's role here purely so these tests keep reading as "signed in as the + // admin" / "signed in as the cashier". + state = Authenticated( + store: store, + user: user, + login: user.role == StaffRole.cashier + ? TerminalLogin.cashier + : TerminalLogin.admin, + ); } @override diff --git a/test/widget/app_smoke_test.dart b/test/widget/app_smoke_test.dart index c2b3686..11f8f2d 100644 --- a/test/widget/app_smoke_test.dart +++ b/test/widget/app_smoke_test.dart @@ -51,7 +51,7 @@ void main() { cashierName: 'Suriya', ); - Future bootApp(WidgetTester tester) async { + Future bootApp(WidgetTester tester, {bool supervisor = true}) async { await tester.pumpWidget( ProviderScope( overrides: [ @@ -70,7 +70,9 @@ void main() { // rather than two constants compiled into the build. A widget test // must not depend on a live endpoint, so the client is swapped for // one that answers with a fixed session. - posAuthApiProvider.overrideWithValue(_FakePosAuthApi()), + posAuthApiProvider.overrideWithValue( + _FakePosAuthApi(canManageStaff: supervisor), + ), // Catalogue reads come from the in-memory cache and resolve on the // spot, but these four go to SQLite. Real disk I/O cannot be driven @@ -167,10 +169,49 @@ void main() { await tester.tap(target.first); await settle(tester); + + // Settings builds a printer-settings controller that reads six values + // out of SQLite, and sqflite arms a ten-second lock-warning timer around + // each. Those reads cannot complete on a fake clock, so the timer would + // still be pending at teardown and the binding would fail the test for + // that rather than for anything it is about. Pumping past the ten + // seconds lets the timer fire and clear. + await tester.pump(const Duration(seconds: 11)); expect(tester.takeException(), isNull, reason: 'opening "$label" threw'); } }); + testWidgets('a cashier session gets billing and nothing else', + (tester) async { + // The other half of the role split, and the half worth pinning: the shell + // a person gets is decided by the back office, not by which tab they + // picked on the way in. Same credentials, same screen size, same boot — + // the only difference is `can_manage_staff` on the session, and the + // back-office modules have to be unreachable because of it. + tester.view.physicalSize = const Size(1800, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await bootApp(tester, supervisor: false); + await signIn(tester); + + // Signed in, and on the billing screen. + expect(find.byType(PosDashboardScreen), findsOneWidget); + + // These labels exist only in the sidebar, so their absence is the whole + // claim: a cashier cannot reach the catalogue, the promos or the + // terminal's configuration. + for (final label in ['Product Import', 'Promo', 'Settings']) { + expect( + find.text(label), + findsNothing, + reason: 'a cashier must not be offered "$label"', + ); + } + + expect(tester.takeException(), isNull); + }); + testWidgets('the back office connection dialog opens and validates', (tester) async { // The only way a shop can point a till at a broker. Until it existed a @@ -185,6 +226,9 @@ void main() { await tester.tap(find.text('Settings').first); await settle(tester); + // Clears sqflite's lock-warning timer — see the module loop above. + await tester.pump(const Duration(seconds: 11)); + await tester.tap(find.text('Configure').first); await settle(tester); @@ -217,7 +261,12 @@ const _testPassword = 'correct-horse'; /// concrete — and answering a wrong password correctly matters here: the login /// screen's failure path is part of what these tests cover. class _FakePosAuthApi extends PosAuthApi { - _FakePosAuthApi() : super(baseUrl: 'https://example.invalid/pos'); + _FakePosAuthApi({this.canManageStaff = true}) + : super(baseUrl: 'https://example.invalid/pos'); + + /// Which shell the back office says this account gets. A supervisor by + /// default, because most of these tests are about the full shell rendering. + final bool canManageStaff; @override Future login({ @@ -241,7 +290,9 @@ class _FakePosAuthApi extends PosAuthApi { userId: 1229, fullName: 'Test Manager', email: _testEmail, - roleId: 0, + roleId: canManageStaff ? 7 : 8, + role: canManageStaff ? 'Supervisor' : 'Cashier', + canManageStaff: canManageStaff, tenantId: 1087, tenantName: 'Ragul Stores', storeId: '1135', diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index d76d2a7..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:nearle_pos/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}