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