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:
@@ -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<StoreRepositoryImpl>(
|
||||
(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 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>(
|
||||
(ref) => ref.watch(storeRepositoryProvider).load(
|
||||
email: DemoCredentials.email,
|
||||
email: ref.watch(authControllerProvider.notifier).session?.email ?? '',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
81
lib/data/local/session_store.dart
Normal file
81
lib/data/local/session_store.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
149
lib/data/remote/pos_auth_api.dart
Normal file
149
lib/data/remote/pos_auth_api.dart
Normal 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();
|
||||
}
|
||||
190
lib/domain/entities/pos_session.dart
Normal file
190
lib/domain/entities/pos_session.dart
Normal 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,
|
||||
};
|
||||
@@ -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<AuthState> {
|
||||
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<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({
|
||||
required String email,
|
||||
required String password,
|
||||
int? locationId,
|
||||
}) async {
|
||||
state = const Authenticating();
|
||||
|
||||
// Stand-in for the network round trip.
|
||||
await Future<void>.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<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 staff = store.staff;
|
||||
|
||||
@@ -78,7 +171,7 @@ class AuthController extends StateNotifier<AuthState> {
|
||||
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<AuthState> {
|
||||
);
|
||||
|
||||
state = Authenticated(store: store, user: opener);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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<void> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<LoginScreen> {
|
||||
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<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 (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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,6 +222,18 @@ final syncBootstrapProvider = FutureProvider<void>((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);
|
||||
|
||||
Reference in New Issue
Block a user