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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user