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>
150 lines
5.0 KiB
Dart
150 lines
5.0 KiB
Dart
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();
|
|
}
|