Sign the terminal in against the back office instead of against two constants

Sign-in compared `admin@nearle.in` / `nearle123` — a compile-time const — after
a 600ms delay standing in for a network call that was never made. Two things
followed, and the second was the serious one.

Every install of a build shared one password, and changing it meant a rebuild.
Worse: 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 the till
asserted which shop it belonged to and the server took its word. One field 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. `DemoCredentials` is gone, along
with the prefilled fields and the "Demo account" hint that printed the password
on the login screen.

The pieces:

- `PosSession` — what the back office answers with. The token is opaque on
  purpose: the till must not parse it or reason about what it appears to say.
- `SessionStore` — the whole session to the platform keystore, not SQLite. The
  token is a bearer credential and SQLite here is a file behind a shop counter.
  An expired session reads back as absent, so no caller has to remember to
  check.
- `SyncConfig.bearerToken` — one accessor rather than the same `??` at each
  call site, because the request that forgot it would be the one silently
  sending no credentials. The session beats a static API key: the key says the
  request 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.
- Restore runs in `syncBootstrapProvider` *before* the engine starts. A drain
  that began first would upload the day's bills unauthenticated. A till trades
  all day; a reboot mid-shift must not put a login screen in front of a queue.
- An outlet picker, shown only when the account genuinely reaches several. Not
  dismissable — defaulting silently to the first outlet is how a day's takings
  end up filed against the wrong shop.

Store name, address, GSTIN and phone now come down with the session and are
written on sign-in. They were compile-time constants, and on a GST invoice
those fields are a legal requirement rather than decoration.

The smoke test signs in through a fake client and inside `runAsync`: sign-in
reaches SQLite now, and real disk I/O cannot complete on a widget test's fake
clock — pumping alone leaves it suspended for ever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-06 15:46:59 +05:30
parent 908058038a
commit b5b2047bcd
12 changed files with 1097 additions and 93 deletions

View File

@@ -17,7 +17,9 @@ import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/store_repository_impl.dart'; import '../data/repositories/store_repository_impl.dart';
import '../data/repositories/sync_repository_impl.dart'; import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart'; import '../data/repositories/transaction_repository_impl.dart';
import '../data/local/session_store.dart';
import '../data/local/terminal_identity.dart'; import '../data/local/terminal_identity.dart';
import '../data/remote/pos_auth_api.dart';
import '../data/sync/sync_engine.dart'; import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart'; import '../domain/repositories/product_repository.dart';
@@ -161,10 +163,28 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)), (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<PosAuthApi>((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<SessionStore>((ref) => SessionStore());
/// The outlet, refreshed whenever staff or details change. /// 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<StoreAccount>( final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load( (ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email, email: ref.watch(authControllerProvider.notifier).session?.email ?? '',
), ),
); );

View File

@@ -35,6 +35,7 @@ class SyncConfig {
this.password, this.password,
this.httpBaseUrl = '', this.httpBaseUrl = '',
this.apiKey, this.apiKey,
this.sessionToken,
this.ackTimeout = const Duration(seconds: 20), this.ackTimeout = const Duration(seconds: 20),
this.batchSize = 50, this.batchSize = 50,
}); });
@@ -52,8 +53,38 @@ class SyncConfig {
final String? password; final String? password;
final String httpBaseUrl; 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; 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 /// How long to wait for the back office to confirm a batch before treating
/// the outcome as unknown and leaving every row pending. /// the outcome as unknown and leaving every row pending.
/// ///
@@ -148,6 +179,7 @@ class SyncConfig {
String? password, String? password,
String? httpBaseUrl, String? httpBaseUrl,
String? apiKey, String? apiKey,
String? sessionToken,
Duration? ackTimeout, Duration? ackTimeout,
int? batchSize, int? batchSize,
}) => }) =>
@@ -162,6 +194,7 @@ class SyncConfig {
password: password ?? this.password, password: password ?? this.password,
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl, httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
apiKey: apiKey ?? this.apiKey, apiKey: apiKey ?? this.apiKey,
sessionToken: sessionToken ?? this.sessionToken,
ackTimeout: ackTimeout ?? this.ackTimeout, ackTimeout: ackTimeout ?? this.ackTimeout,
batchSize: batchSize ?? this.batchSize, batchSize: batchSize ?? this.batchSize,
); );

View File

@@ -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<PosSession?> read({DateTime? now}) async {
final String? raw;
try {
raw = await _secure.read(key: _key);
} on Object catch (e) {
// No keystore — a headless test host, or a Linux box with no secret
// service. Signing in again is the safe way to fail.
debugPrint('Secure storage unavailable, session not loaded: $e');
return null;
}
if (raw == null || raw.isEmpty) return null;
try {
final session =
PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
if (!session.isValidAt(now ?? DateTime.now())) return null;
if (session.token.isEmpty || session.locationId <= 0) return null;
return session;
} on Object catch (e) {
// A stored session this build cannot parse — most likely written by an
// older one. Dropped rather than repaired: a half-understood session is
// worse than none, and re-authenticating costs one screen.
debugPrint('Stored session could not be read, discarding: $e');
return null;
}
}
Future<void> write(PosSession session) async {
try {
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
} on Object catch (e) {
// The terminal keeps working on the session it holds in memory; it just
// will not survive a restart. Failing the sign-in over this would close a
// shop for a keystore problem.
debugPrint('Could not persist the session: $e');
}
}
Future<void> clear() async {
try {
await _secure.delete(key: _key);
} on Object catch (e) {
debugPrint('Could not clear the session: $e');
}
}
}

View File

@@ -156,7 +156,8 @@ class HttpCatalogueSource implements CatalogueSource {
uri, uri,
headers: { headers: {
'accept': 'application/json', 'accept': 'application/json',
if (config.apiKey != null) 'authorization': 'Bearer ${config.apiKey}', if (config.bearerToken != null)
'authorization': 'Bearer ${config.bearerToken}',
}, },
).timeout(_timeout); ).timeout(_timeout);
} on Exception catch (e) { } on Exception catch (e) {

View File

@@ -86,8 +86,8 @@ class HttpOrderTransport implements OrderTransport {
Uri.parse('${config.httpBaseUrl}/health'), Uri.parse('${config.httpBaseUrl}/health'),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
if (config.apiKey != null) if (config.bearerToken != null)
'authorization': 'Bearer ${config.apiKey}', 'authorization': 'Bearer ${config.bearerToken}',
}, },
body: payload, body: payload,
) )
@@ -133,8 +133,8 @@ class HttpOrderTransport implements OrderTransport {
uri, uri,
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
if (config.apiKey != null) if (config.bearerToken != null)
'authorization': 'Bearer ${config.apiKey}', 'authorization': 'Bearer ${config.bearerToken}',
'idempotency-key': batchId, 'idempotency-key': batchId,
}, },
body: jsonEncode({ body: jsonEncode({

View File

@@ -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<PosSession> login({
required String authname,
required String password,
String? terminalId,
String? deviceId,
int? locationId,
int? configId,
}) async {
if (baseUrl.isEmpty) {
throw const PosAuthException(
'This terminal has no back office configured. Set the endpoint in '
'Settings → Connectivity & sync.',
);
}
final body = <String, Object?>{
'authname': authname.trim(),
'password': password,
if (terminalId != null && terminalId.isNotEmpty) 'terminal_id': terminalId,
if (deviceId != null && deviceId.isNotEmpty) 'device_id': deviceId,
if (locationId != null && locationId > 0) 'location_id': locationId,
// Sent only when known. The backend infers it when absent, and a shop
// has no way to find out what its configid is.
if (configId != null && configId > 0) 'configid': configId,
};
final http.Response response;
try {
response = await _client
.post(
Uri.parse('$baseUrl/login'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode(body),
)
.timeout(_timeout);
} on TimeoutException {
throw const PosAuthException(
'The back office did not answer in time. Check the connection and try '
'again.',
);
} on Object {
throw const PosAuthException(
'Could not reach the back office. Check the connection and try again.',
);
}
Map<String, Object?> decoded;
try {
decoded = jsonDecode(response.body) as Map<String, Object?>;
} on Object {
throw PosAuthException(
'The back office answered with something this terminal could not read '
'(HTTP ${response.statusCode}).',
);
}
if (response.statusCode != 200) {
throw PosAuthException(
(decoded['message'] as String?) ??
'Sign-in was refused (HTTP ${response.statusCode}).',
// 401 is a wrong email or password; 403 is a real account that may not
// open this till. Only the first is worth re-typing.
isCredentialFailure: response.statusCode == 401,
);
}
final details = decoded['details'];
if (details is! Map<String, Object?>) {
throw const PosAuthException(
'The back office accepted the sign-in but returned no session.',
);
}
final session = PosSession.fromJson(details);
// A session with no token cannot authenticate anything, and one with no
// outlet cannot bill. Refused here rather than being saved and failing
// later against every request, which would be much harder to diagnose.
if (session.token.isEmpty) {
throw const PosAuthException(
'The back office returned a session with no token.',
);
}
if (session.locationId <= 0) {
throw const PosAuthException(
'This account is not attached to an outlet, so it cannot open a till.',
);
}
return session;
}
void dispose() => _client.close();
}

View File

@@ -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<PosOutlet> 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<String, Object?> json) {
final outlets = (json['locations'] as List<Object?>? ?? const [])
.whereType<Map<String, Object?>>()
.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<String, Object?> 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<String, Object?> json) => PosOutlet(
locationId: _int(json['location_id']),
locationName: (json['location_name'] as String?) ?? '',
address: (json['address'] as String?) ?? '',
city: (json['city'] as String?) ?? '',
);
Map<String, Object?> 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,
};

View File

@@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart'; import '../../../app/providers.dart';
import '../../../data/remote/pos_auth_api.dart';
import '../../../domain/entities/pos_session.dart';
import '../../../domain/entities/store_account.dart'; import '../../../domain/entities/store_account.dart';
/// Sign-in state for the terminal. /// Sign-in state for the terminal.
@@ -31,46 +33,137 @@ class AuthFailure extends AuthState {
final String message; 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 /// This used to compare against two constants compiled into the app —
/// person's, and it is replaced wholesale when the terminal is registered /// `admin@nearle.in` / `nearle123` — with a 600ms delay standing in for a
/// against a real back office. Staff PINs — the credential that actually opens /// network call that was never made. Two things were wrong with that, and the
/// a till drawer — are no longer here. They live hashed in the database. /// second was the serious one:
class DemoCredentials { ///
const DemoCredentials._(); /// 1. every install of a build shared one password, and changing it meant a
/// rebuild; and
static const String email = 'admin@nearle.in'; /// 2. because nothing was checked with the back office, the *outlet* could not
static const String password = 'nearle123'; /// 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.
/// Validates store credentials and holds the signed-in session. ///
/// 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<AuthState> { class AuthController extends StateNotifier<AuthState> {
AuthController(this._ref) : super(const Unauthenticated()); AuthController(this._ref) : super(const Unauthenticated());
final Ref _ref; 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<bool> restore() async {
final saved = await _ref.read(sessionStoreProvider).read();
if (saved == null) return false;
await _adopt(saved);
return state is Authenticated;
}
Future<bool> signIn({ Future<bool> signIn({
required String email, required String email,
required String password, required String password,
int? locationId,
}) async { }) async {
state = const Authenticating(); state = const Authenticating();
// Stand-in for the network round trip. final terminal = _ref.read(terminalIdentityProvider);
await Future<void>.delayed(const Duration(milliseconds: 600));
final normalised = email.trim().toLowerCase(); final PosSession session;
try {
if (normalised != DemoCredentials.email) { session = await _ref.read(posAuthApiProvider).login(
state = const AuthFailure('No store is registered against that email.'); 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; return false;
} }
if (password != DemoCredentials.password) { await _ref.read(sessionStoreProvider).write(session);
state = const AuthFailure('Incorrect password. Please try again.'); await _adopt(session);
return false;
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<bool> 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<void> _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 store = await _ref.read(storeAccountProvider.future);
final staff = store.staff; final staff = store.staff;
@@ -78,7 +171,7 @@ class AuthController extends StateNotifier<AuthState> {
state = const AuthFailure( state = const AuthFailure(
'This terminal has no staff accounts. Reinstall to seed them.', '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 // The first admin, or whoever is there. A person switches to their own
@@ -89,7 +182,6 @@ class AuthController extends StateNotifier<AuthState> {
); );
state = Authenticated(store: store, user: opener); state = Authenticated(store: store, user: opener);
return true;
} }
/// Switches the active operator, checking their PIN. /// Switches the active operator, checking their PIN.
@@ -130,8 +222,18 @@ class AuthController extends StateNotifier<AuthState> {
/// answers with, never a catalogue instance carried over from this /// answers with, never a catalogue instance carried over from this
/// session — so the local product table is dropped before the session /// session — so the local product table is dropped before the session
/// itself is. /// 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<void> signOut() async { Future<void> signOut() async {
await _ref.read(localStoreProvider).clearCatalogue(); 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(); state = const Unauthenticated();
} }

View File

@@ -9,6 +9,7 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/validators.dart'; import '../../../core/utils/validators.dart';
import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/pos_session.dart';
import '../providers/auth_controller.dart'; import '../providers/auth_controller.dart';
/// Store sign-in. The terminal shows this until a valid account is entered. /// 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<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _email = TextEditingController(text: DemoCredentials.email); final _email = TextEditingController();
final _password = TextEditingController(text: DemoCredentials.password); final _password = TextEditingController();
bool _obscure = true; bool _obscure = true;
bool _rememberTerminal = true; bool _rememberTerminal = true;
@@ -38,12 +39,42 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
if (!(_formKey.currentState?.validate() ?? false)) return; 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, email: _email.text,
password: _password.text, password: _password.text,
); );
if (!ok || !mounted) return;
if (ok && mounted) context.go(AppRoutes.pos); // 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<PosOutlet>(
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 (mounted) context.go(AppRoutes.pos);
} }
@override @override
@@ -432,15 +463,6 @@ class _FormPanel extends ConsumerWidget {
onPressed: onSubmit, onPressed: onSubmit,
), ),
const SizedBox(height: AppSpacing.xl),
_DemoHint(
onFill: busy
? null
: () {
email.text = DemoCredentials.email;
password.text = DemoCredentials.password;
},
),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),
Center( Center(
@@ -483,59 +505,100 @@ class _Label extends StatelessWidget {
} }
} }
class _DemoHint extends StatelessWidget { /// Asks which of the signed-in account's outlets this terminal is standing in.
const _DemoHint({this.onFill}); ///
/// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return AlertDialog(
padding: const EdgeInsets.all(AppSpacing.md), backgroundColor: AppColors.surface,
decoration: BoxDecoration( shape: RoundedRectangleBorder(borderRadius: AppRadius.brMd),
color: AppColors.primarySurface, title: const Text(
borderRadius: AppRadius.brSm, 'Which outlet is this terminal at?',
border: Border.all(color: AppColors.primaryBorder), style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
), ),
child: Row( content: SizedBox(
crossAxisAlignment: CrossAxisAlignment.start, width: 380,
children: [
const Icon(Icons.info_outline_rounded,
size: 17, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Padding(
'Demo account', padding: const EdgeInsets.only(bottom: AppSpacing.md),
style: TextStyle( child: Text(
'Signed in as ${session.fullName.isNotEmpty ? session.fullName : session.email}'
' · ${session.tenantName}',
style: const TextStyle(
fontSize: 12.5, fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
SizedBox(height: 2),
SelectableText(
'${DemoCredentials.email} · ${DemoCredentials.password}',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
),
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),
);
},
),
),
], ],
), ),
), ),
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)),
),
],
),
); );
} }
} }

View File

@@ -222,6 +222,18 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
await store.syncConfig.load(ref.read(syncConfigProvider)); 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(); await ref.read(connectivityServiceProvider).start();
final engine = ref.read(syncEngineProvider); final engine = ref.read(syncEngineProvider);

View File

@@ -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<String, Object?>,
);
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<PosAuthException>()
.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<PosAuthException>()
.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<PosAuthException>()),
);
});
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<PosAuthException>()),
);
});
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<PosAuthException>().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<http.Response> Function(http.BaseRequest) _handler;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final response = await _handler(request);
return http.StreamedResponse(
Stream.value(response.bodyBytes),
response.statusCode,
headers: response.headers,
);
}
}

View File

@@ -5,10 +5,11 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:nearle_pos/app/app.dart'; import 'package:nearle_pos/app/app.dart';
import 'package:nearle_pos/data/datasources/local_store.dart'; import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.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/app/providers.dart';
import 'package:nearle_pos/domain/entities/shift_report.dart'; import 'package:nearle_pos/domain/entities/shift_report.dart';
import 'package:nearle_pos/domain/entities/store_account.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/providers/cart_controller.dart';
import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart'; import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart';
import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart'; import 'package:nearle_pos/presentation/sync/providers/sync_controller.dart';
@@ -35,7 +36,7 @@ void main() {
const testStore = StoreAccount( const testStore = StoreAccount(
id: 'store-001', id: 'store-001',
name: 'Nearle Daily', name: 'Nearle Daily',
email: DemoCredentials.email, email: 'manager@ragulstores.test',
address: '1 Test Street', address: '1 Test Street',
gstin: '33AABCU9603R1ZM', gstin: '33AABCU9603R1ZM',
phone: '9840000000', phone: '9840000000',
@@ -65,6 +66,12 @@ void main() {
// fail on a screen that never arrived. // fail on a screen that never arrived.
storeAccountProvider.overrideWith((ref) async => testStore), 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 // 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 // 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 // by the fake clock a widget test runs on: sqflite's own lock-warning
@@ -96,11 +103,20 @@ void main() {
Future<void> signIn(WidgetTester tester) async { Future<void> signIn(WidgetTester tester) async {
final fields = find.byType(TextFormField); final fields = find.byType(TextFormField);
await tester.enterText(fields.first, DemoCredentials.email); await tester.enterText(fields.first, _testEmail);
await tester.enterText(fields.at(1), DemoCredentials.password); await tester.enterText(fields.at(1), _testPassword);
await tester.pump(); await tester.pump();
// 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 tester.tap(find.text('Sign in').last);
await Future<void>.delayed(const Duration(milliseconds: 200));
});
await settle(tester); await settle(tester);
} }
@@ -191,3 +207,52 @@ void main() {
expect(tester.takeException(), isNull); 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<PosSession> 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() {}
}