diff --git a/lib/app/providers.dart b/lib/app/providers.dart index c04e1c5..140f473 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 +/// `DemoCredentials.email` — the same address on every install of a build, +/// 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/remote/http_catalogue_source.dart b/lib/data/remote/http_catalogue_source.dart index e6d6a28..f8db208 100644 --- a/lib/data/remote/http_catalogue_source.dart +++ b/lib/data/remote/http_catalogue_source.dart @@ -156,7 +156,8 @@ class HttpCatalogueSource implements CatalogueSource { uri, headers: { 'accept': 'application/json', - if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}', + if (config.bearerToken != null) + 'authorization': 'Bearer ${config.bearerToken}', }, ).timeout(_timeout); } on Exception catch (e) { diff --git a/lib/data/remote/http_order_transport.dart b/lib/data/remote/http_order_transport.dart index 766080d..2532683 100644 --- a/lib/data/remote/http_order_transport.dart +++ b/lib/data/remote/http_order_transport.dart @@ -86,8 +86,8 @@ class HttpOrderTransport implements OrderTransport { Uri.parse('${config.httpBaseUrl}/health'), headers: { 'content-type': 'application/json', - if (config.apiKey != null) - 'authorization': 'Bearer ${config.apiKey}', + if (config.bearerToken != null) + 'authorization': 'Bearer ${config.bearerToken}', }, body: payload, ) @@ -133,8 +133,8 @@ class HttpOrderTransport implements OrderTransport { uri, headers: { 'content-type': 'application/json', - if (config.apiKey != null) - 'authorization': 'Bearer ${config.apiKey}', + if (config.bearerToken != null) + 'authorization': 'Bearer ${config.bearerToken}', 'idempotency-key': batchId, }, body: jsonEncode({ 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..97f218b --- /dev/null +++ b/lib/domain/entities/pos_session.dart @@ -0,0 +1,190 @@ +/// 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.gstin = '', + this.address = '', + this.phone = '', + this.outlets = 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; + + 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; + + /// 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, + 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, + ); + + 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']), + 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, + ); + } + + Map toJson() => { + 'token': token, + 'expires_at': expiresAt.toIso8601String(), + 'user_id': userId, + 'full_name': fullName, + 'email': email, + 'role_id': roleId, + '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(), + }; +} + +/// 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, + }; diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index ad5a354..b82f127 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.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. @@ -31,46 +33,137 @@ class AuthFailure extends AuthState { final String message; } -/// Store-level credentials for the unregistered build. +/// Signs the terminal in against the back office and holds the session. /// -/// Still a constant, and deliberately so: this is the *store* login, not a -/// person's, and it is replaced wholesale when the terminal is registered -/// against a real back office. Staff PINs — the credential that actually opens -/// a till drawer — are no longer here. They live hashed in the database. -class DemoCredentials { - const DemoCredentials._(); - - static const String email = 'admin@nearle.in'; - static const String password = 'nearle123'; -} - -/// Validates store credentials and holds the signed-in session. +/// This used to compare against two constants compiled into the app — +/// `admin@nearle.in` / `nearle123` — 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 the outlet +/// arrives 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; + + /// 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 normalised = email.trim().toLowerCase(); - - if (normalised != DemoCredentials.email) { - state = const AuthFailure('No store 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 != DemoCredentials.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, + ); + + _ref.invalidate(storeAccountProvider); final store = await _ref.read(storeAccountProvider.future); final staff = store.staff; @@ -78,7 +171,7 @@ class AuthController extends StateNotifier { state = const AuthFailure( 'This terminal has no staff accounts. Reinstall to seed them.', ); - return false; + return; } // The first admin, or whoever is there. A person switches to their own @@ -89,7 +182,6 @@ class AuthController extends StateNotifier { ); state = Authenticated(store: store, user: opener); - return true; } /// Switches the active operator, checking their PIN. @@ -130,8 +222,18 @@ class AuthController extends StateNotifier { /// answers with, never a catalogue instance carried over from this /// session — so the local product table is dropped before the session /// itself is. + /// + /// The token goes with it, 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. Future signOut() async { 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 11ec5ea..17b89e2 100644 --- a/lib/presentation/auth/screens/login_screen.dart +++ b/lib/presentation/auth/screens/login_screen.dart @@ -9,6 +9,7 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/validators.dart'; import '../../../core/widgets/primary_button.dart'; +import '../../../domain/entities/pos_session.dart'; import '../providers/auth_controller.dart'; /// Store sign-in. The terminal shows this until a valid account is entered. @@ -21,8 +22,8 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); - final _email = TextEditingController(text: DemoCredentials.email); - final _password = TextEditingController(text: DemoCredentials.password); + final _email = TextEditingController(); + final _password = TextEditingController(); bool _obscure = true; bool _rememberTerminal = true; @@ -38,12 +39,42 @@ class _LoginScreenState extends ConsumerState { FocusScope.of(context).unfocus(); if (!(_formKey.currentState?.validate() ?? false)) return; - final ok = await ref.read(authControllerProvider.notifier).signIn( + final auth = ref.read(authControllerProvider.notifier); + + var ok = await auth.signIn( + email: _email.text, + password: _password.text, + ); + if (!ok || !mounted) return; + + // A proprietor with several shops signs in once and then says which counter + // this terminal is standing at. The back office has already decided which + // outlets they may reach, so this is a choice among those — never a free + // text field, which is what the old Settings store id amounted to. + final session = auth.session; + if (session != null && session.hasChoiceOfOutlet) { + final chosen = await showDialog( + context: context, + // Not dismissable: a terminal has to be standing somewhere, and + // defaulting silently to the first outlet is how a day's takings end up + // filed against the wrong shop. + barrierDismissible: false, + builder: (_) => _OutletPicker(session: session), + ); + + if (chosen == null || !mounted) return; + + if (chosen.locationId != session.locationId) { + ok = await auth.signIn( email: _email.text, password: _password.text, + locationId: chosen.locationId, ); + if (!ok || !mounted) return; + } + } - if (ok && mounted) context.go(AppRoutes.pos); + if (mounted) context.go(AppRoutes.pos); } @override @@ -432,15 +463,6 @@ class _FormPanel extends ConsumerWidget { onPressed: onSubmit, ), - const SizedBox(height: AppSpacing.xl), - _DemoHint( - onFill: busy - ? null - : () { - email.text = DemoCredentials.email; - password.text = DemoCredentials.password; - }, - ), const SizedBox(height: AppSpacing.xxl), Center( @@ -483,58 +505,99 @@ class _Label extends StatelessWidget { } } -class _DemoHint extends StatelessWidget { - const _DemoHint({this.onFill}); +/// Asks which of the signed-in account's outlets this terminal is standing in. +/// +/// Only shown when there is genuinely a choice. A shop manager pinned to one +/// location never sees it, which is the common case — this is for a proprietor +/// whose account reaches several shops. +/// +/// The list comes from the back office and cannot be typed into. That is the +/// whole difference from what this replaced: the outlet used to be a store id +/// entered in Settings, so a till asserted which shop it belonged to. Here it +/// picks from what the account is already entitled to, and the choice is +/// re-checked server-side when the new session is issued. +class _OutletPicker extends StatelessWidget { + const _OutletPicker({required this.session}); - final VoidCallback? onFill; + final PosSession session; @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), + return AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder(borderRadius: AppRadius.brMd), + title: const Text( + 'Which outlet is this terminal at?', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.info_outline_rounded, - size: 17, color: AppColors.primary,), - const SizedBox(width: AppSpacing.sm), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Demo account', - style: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w600, - color: AppColors.primary, - ), + content: SizedBox( + width: 380, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Text( + 'Signed in as ${session.fullName.isNotEmpty ? session.fullName : session.email}' + ' · ${session.tenantName}', + style: const TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, ), - SizedBox(height: 2), - SelectableText( - '${DemoCredentials.email} · ${DemoCredentials.password}', - style: TextStyle( - fontSize: 12, - color: AppColors.textSecondary, - ), - ), - ], + ), ), - ), - TextButton( - onPressed: onFill, - style: TextButton.styleFrom( - minimumSize: const Size(0, 32), - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: session.outlets.length, + separatorBuilder: (_, __) => + const SizedBox(height: AppSpacing.xs), + itemBuilder: (context, i) { + final outlet = session.outlets[i]; + final isCurrent = outlet.locationId == session.locationId; + + return ListTile( + dense: true, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.brSm, + side: BorderSide( + color: isCurrent + ? AppColors.primaryBorder + : AppColors.border, + ), + ), + tileColor: + isCurrent ? AppColors.primarySurface : AppColors.surface, + title: Text( + outlet.locationName.isNotEmpty + ? outlet.locationName + : 'Outlet ${outlet.locationId}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + // The numeric id is shown deliberately. It is what appears + // in the back office, on a support call and in every log + // line, so a person can match what they are looking at. + subtitle: Text( + [ + 'ID ${outlet.locationId}', + if (outlet.city.isNotEmpty) outlet.city, + ].join(' · '), + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textTertiary, + ), + ), + onTap: () => Navigator.of(context).pop(outlet), + ); + }, + ), ), - child: const Text('Fill', style: TextStyle(fontSize: 12.5)), - ), - ], + ], + ), ), ); } 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/test/unit/pos_session_test.dart b/test/unit/pos_session_test.dart new file mode 100644 index 0000000..99981e8 --- /dev/null +++ b/test/unit/pos_session_test.dart @@ -0,0 +1,288 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:nearle_pos/core/config/sync_config.dart'; +import 'package:nearle_pos/data/remote/pos_auth_api.dart'; +import 'package:nearle_pos/domain/entities/pos_session.dart'; + +/// Sign-in used to be two constants compiled into the app, compared after a +/// fake 600ms delay. The store id came from a field in Settings, so a till +/// named its own outlet and was believed — one number changed on one screen +/// moved a terminal into another tenant's books. +/// +/// These cover the replacement: the outlet arrives *from* the back office, and +/// everything the till does with that answer. + +void main() { + group('a session read off the wire', () { + test('takes its outlet from the back office, not from the till', () { + final session = PosSession.fromJson({ + 'token': 'abc.def', + 'expires_at': '2026-09-05T10:00:00Z', + 'user_id': 1229, + 'full_name': 'Selvapuram', + 'tenant_id': 1087, + 'tenant_name': 'Ragul Stores', + 'store_id': '1135', + 'location_id': 1135, + 'location_name': 'Ragul stores Selvapuram', + }); + + expect(session.storeId, '1135'); + expect(session.locationId, 1135); + expect(session.tenantId, 1087); + }); + + test('reads an id whether it arrives quoted or bare', () { + // The backend sends `location_id` as a number and `store_id` as a string + // for the same value. A till that accepted only one shape would read zero + // for the other — which looks like "no outlet" rather than like a bug. + final quoted = PosSession.fromJson({ + 'location_id': '1135', + 'token': 't', + 'expires_at': '2026-09-05T10:00:00Z', + }); + final bare = PosSession.fromJson({ + 'location_id': 1135, + 'token': 't', + 'expires_at': '2026-09-05T10:00:00Z', + }); + + expect(quoted.locationId, 1135); + expect(bare.locationId, 1135); + }); + + test('falls back to the location id when no store id is sent', () { + final session = PosSession.fromJson({ + 'token': 't', + 'expires_at': '2026-09-05T10:00:00Z', + 'location_id': 1135, + }); + + expect(session.storeId, '1135'); + }); + + test('an unreadable expiry counts as already finished', () { + // Guessing "valid" here would keep a till sending a token the server + // stopped honouring hours ago, and reading the resulting refusals as a + // server fault. + final session = PosSession.fromJson({ + 'token': 't', + 'location_id': 1135, + 'expires_at': 'not a date', + }); + + expect(session.isValidAt(DateTime.now()), isFalse); + }); + + test('survives a round trip through storage', () { + final original = PosSession.fromJson({ + 'token': 'abc.def', + 'expires_at': '2026-09-05T10:00:00Z', + 'user_id': 1305, + 'full_name': 'Gokul R', + 'email': 'raguladmin@example.test', + 'tenant_id': 1087, + 'tenant_name': 'Ragul Stores', + 'store_id': '1097', + 'location_id': 1097, + 'location_name': 'Ragul stores', + 'gstin': '33AABCU9603R1ZM', + 'locations': [ + {'location_id': 1097, 'location_name': 'Ragul stores'}, + {'location_id': 1135, 'location_name': 'Ragul stores Selvapuram'}, + ], + }); + + final restored = PosSession.fromJson( + jsonDecode(jsonEncode(original.toJson())) as Map, + ); + + expect(restored.token, original.token); + expect(restored.locationId, original.locationId); + expect(restored.gstin, original.gstin); + expect(restored.outlets.length, 2); + expect(restored.expiresAt.toUtc(), original.expiresAt.toUtc()); + }); + + test('offers a choice only when there is one', () { + final single = PosSession.fromJson({ + 'token': 't', + 'expires_at': '2026-09-05T10:00:00Z', + 'location_id': 1135, + 'locations': [ + {'location_id': 1135, 'location_name': 'Selvapuram'}, + ], + }); + final several = PosSession.fromJson({ + 'token': 't', + 'expires_at': '2026-09-05T10:00:00Z', + 'location_id': 1097, + 'locations': [ + {'location_id': 1097, 'location_name': 'Ragul stores'}, + {'location_id': 1135, 'location_name': 'Selvapuram'}, + ], + }); + + expect(single.hasChoiceOfOutlet, isFalse); + expect(several.hasChoiceOfOutlet, isTrue); + }); + }); + + group('the session is what authenticates a request', () { + test('takes precedence over a static api key', () { + // The key says "this came from our fleet". The session says which outlet + // it came from — and only the second can stop a till reaching another + // tenant's books. + const config = SyncConfig( + apiKey: 'fleet-wide-key', + sessionToken: 'per-user-session', + ); + + expect(config.bearerToken, 'per-user-session'); + }); + + test('falls back to the api key before a terminal has signed in', () { + const config = SyncConfig(apiKey: 'fleet-wide-key'); + + expect(config.bearerToken, 'fleet-wide-key'); + }); + + test('an emptied session does not authenticate as itself', () { + // Sign-out clears the token by writing an empty string rather than by + // rebuilding the config. If that read as a credential, a signed-out till + // would keep uploading as the shop that signed in this morning. + const config = SyncConfig(sessionToken: '', apiKey: ''); + + expect(config.bearerToken, isNull); + }); + }); + + group('signing in against the back office', () { + PosAuthApi apiReturning(int status, Object body) => PosAuthApi( + baseUrl: 'https://example.invalid/pos', + client: MockClient( + (_) async => http.Response(jsonEncode(body), status, + headers: {'content-type': 'application/json'}), + ), + ); + + test('returns the outlet the back office named', () async { + final api = apiReturning(200, { + 'code': 200, + 'status': true, + 'details': { + 'token': 'abc.def', + 'expires_at': '2026-09-05T10:00:00Z', + 'location_id': 1135, + 'store_id': '1135', + 'location_name': 'Ragul stores Selvapuram', + }, + }); + + final session = await api.login(authname: 'a@b.test', password: 'pw'); + + expect(session.storeId, '1135'); + expect(session.token, 'abc.def'); + }); + + test('a wrong password is reported as one worth re-typing', () async { + final api = apiReturning(401, { + 'code': 401, + 'status': false, + 'message': 'those sign-in details were not recognised', + }); + + await expectLater( + api.login(authname: 'a@b.test', password: 'wrong'), + throwsA( + isA() + .having((e) => e.isCredentialFailure, 'credential failure', true), + ), + ); + }); + + test('a refused outlet is not reported as a wrong password', () async { + // 403 is a real account that may not open this till. Telling someone to + // re-type a password that was correct sends them round a loop. + final api = apiReturning(403, { + 'code': 403, + 'status': false, + 'message': 'this account cannot open a till at outlet 1185', + }); + + await expectLater( + api.login(authname: 'a@b.test', password: 'pw'), + throwsA( + isA() + .having((e) => e.isCredentialFailure, 'credential failure', false), + ), + ); + }); + + test('a session with no token is refused rather than saved', () async { + // Saving it would fail against every later request instead of here, which + // is much harder to diagnose from a shop floor. + final api = apiReturning(200, { + 'code': 200, + 'status': true, + 'details': {'location_id': 1135, 'expires_at': '2026-09-05T10:00:00Z'}, + }); + + await expectLater( + api.login(authname: 'a@b.test', password: 'pw'), + throwsA(isA()), + ); + }); + + test('a session naming no outlet is refused', () async { + final api = apiReturning(200, { + 'code': 200, + 'status': true, + 'details': {'token': 'abc.def', 'expires_at': '2026-09-05T10:00:00Z'}, + }); + + await expectLater( + api.login(authname: 'a@b.test', password: 'pw'), + throwsA(isA()), + ); + }); + + test('an unconfigured terminal says so instead of failing obscurely', + () async { + final api = PosAuthApi(baseUrl: ''); + + await expectLater( + api.login(authname: 'a@b.test', password: 'pw'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('no back office configured'), + ), + ), + ); + }); + }); +} + +/// A client answering with one canned response. +/// +/// Hand-rolled rather than pulled from `http/testing.dart` so the test suite +/// does not gain a dependency for four lines. +class MockClient extends http.BaseClient { + MockClient(this._handler); + + final Future Function(http.BaseRequest) _handler; + + @override + Future send(http.BaseRequest request) async { + final response = await _handler(request); + return http.StreamedResponse( + Stream.value(response.bodyBytes), + response.statusCode, + headers: response.headers, + ); + } +} diff --git a/test/widget/app_smoke_test.dart b/test/widget/app_smoke_test.dart index 9bdb57e..c2b3686 100644 --- a/test/widget/app_smoke_test.dart +++ b/test/widget/app_smoke_test.dart @@ -5,10 +5,11 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:nearle_pos/app/app.dart'; import 'package:nearle_pos/data/datasources/local_store.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart'; +import 'package:nearle_pos/data/remote/pos_auth_api.dart'; +import 'package:nearle_pos/domain/entities/pos_session.dart'; import 'package:nearle_pos/app/providers.dart'; import 'package:nearle_pos/domain/entities/shift_report.dart'; import 'package:nearle_pos/domain/entities/store_account.dart'; -import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart'; import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart'; import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart'; import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart'; @@ -35,7 +36,7 @@ void main() { const testStore = StoreAccount( id: 'store-001', name: 'Nearle Daily', - email: DemoCredentials.email, + email: 'manager@ragulstores.test', address: '1 Test Street', gstin: '33AABCU9603R1ZM', phone: '9840000000', @@ -65,6 +66,12 @@ void main() { // fail on a screen that never arrived. storeAccountProvider.overrideWith((ref) async => testStore), + // Sign-in is a network call now — a person's own back-office account + // 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()), + // 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 // by the fake clock a widget test runs on: sqflite's own lock-warning @@ -96,11 +103,20 @@ void main() { Future signIn(WidgetTester tester) async { final fields = find.byType(TextFormField); - await tester.enterText(fields.first, DemoCredentials.email); - await tester.enterText(fields.at(1), DemoCredentials.password); + await tester.enterText(fields.first, _testEmail); + await tester.enterText(fields.at(1), _testPassword); await tester.pump(); - await tester.tap(find.text('Sign in').last); + // Sign-in reaches SQLite now: it writes the outlet the back office named + // and the store details a receipt is legally required to carry, before the + // shell opens. Real disk I/O cannot complete on a widget test's fake clock, + // so the tap runs inside runAsync — pumping alone leaves the sign-in + // suspended for ever and every later assertion fails on a screen that never + // arrived. + await tester.runAsync(() async { + await tester.tap(find.text('Sign in').last); + await Future.delayed(const Duration(milliseconds: 200)); + }); await settle(tester); } @@ -191,3 +207,52 @@ void main() { expect(tester.takeException(), isNull); }); } + +const _testEmail = 'manager@ragulstores.test'; +const _testPassword = 'correct-horse'; + +/// A back office that accepts one account and refuses everything else. +/// +/// Subclasses rather than reimplements an interface because the real client is +/// 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'); + + @override + Future login({ + required String authname, + required String password, + String? terminalId, + String? deviceId, + int? locationId, + int? configId, + }) async { + if (authname.trim() != _testEmail || password != _testPassword) { + throw const PosAuthException( + 'those sign-in details were not recognised', + isCredentialFailure: true, + ); + } + + return PosSession( + token: 'test-session-token', + expiresAt: DateTime.now().add(const Duration(days: 30)), + userId: 1229, + fullName: 'Test Manager', + email: _testEmail, + roleId: 0, + tenantId: 1087, + tenantName: 'Ragul Stores', + storeId: '1135', + locationId: 1135, + locationName: 'Ragul stores Selvapuram', + outlets: const [ + PosOutlet(locationId: 1135, locationName: 'Ragul stores Selvapuram'), + ], + ); + } + + @override + void dispose() {} +}