Files
nearle_pos/lib/domain/entities/pos_session.dart
Suriya 829e5a8188 Take the terminal's role from the back office, not from which tab was clicked
The role split was right; only its source was wrong. Signing in matched what
was typed against two constants compiled into the app — admin@nearle.in and
cashier@nearle.in — so which shell a person got was a property of the *build*.
A shop could not add a third person, revoke either of the two it had, or stop
anyone with the APK reading both passwords out of it.

TerminalLogin survives unchanged in shape, because the shape was the good part:
one flag the shell reads, a session that decides it, and a cashier sign-out that
takes the catalogue with it while a supervisor's leaves it behind. Every
consumer — visibleModulesProvider, resolvedModuleProvider, the sidebar, the page
header, the sign-out dialog — is untouched. What changed is that the enum is now
only constructible from a session the back office signed, so there is no path
left where the terminal grants itself a permission the server did not send.

It reads `can_manage_staff` rather than the role name or id. app_roles holds six
rows for four distinct roles, a great many accounts carry a roleid that is not
in the table at all, and the name comes back blank for most of them. Matching on
either would mean shipping a copy of the role table in the app and keeping the
two in step for ever. One boolean, decided server-side, cannot drift. It
defaults to false, which matters on the restore path: a session saved by a build
that predates the field comes back as a cashier, never silently as an admin.

This also restores the sign-in layer itself — pos_auth_api, pos_session,
session_store, the staff import and the bearer token — which an earlier commit
removed wholesale from a stale checkout. Its parent was the commit that added
them, so the deletion was a bad merge rather than a decision; the terminal has
been running on the two constants since.

The login screen loses its role tabs and its credential prefill. You do not
choose what you are on the way in.

The opener is now matched on the back office user id rather than on the first
account with a matching role, so the first bill of a shift is attributed to
whoever actually signed in.

Tests: the smoke suite pinned only the supervisor shell, and it was passing for
the wrong reason — the fake session omitted can_manage_staff, and the sidebar it
asserted on was there because the role was hardcoded. Both halves are pinned now
and the fake is parameterised. widget_test.dart was the stock Flutter counter
template, restored by the same bad merge, testing a MyApp that has never existed
in this repo.

292 tests pass; analyzer reports no errors and no warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:06:00 +05:30

282 lines
9.9 KiB
Dart

/// 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.role = '',
this.canManageStaff = false,
this.gstin = '',
this.address = '',
this.phone = '',
this.outlets = const [],
this.staff = 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;
/// The back office's name for [roleId] — "Supervisor", "Cashier", "Admin".
///
/// For display. Never branch on it: `app_roles` holds six rows for four
/// distinct roles, and a great many accounts carry a `roleid` that is not in
/// the table at all and come back blank. [canManageStaff] is the flag to
/// read.
final String role;
/// Whether this account runs the terminal or only bills on it.
///
/// The one permission the till acts on, and it is answered by the back office
/// rather than worked out here. A supervisor gets the full shell; a cashier
/// gets the billing screen. Deciding it locally would mean shipping a copy of
/// the role table in the app and keeping the two in step for ever.
///
/// Defaults to false, which is the least-privileged answer. That matters on
/// the restore path: a session saved by a build that predates this field
/// comes back as a cashier rather than silently as an admin.
final bool canManageStaff;
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;
/// The people the back office says may ring a bill here.
///
/// Very often empty. Only 116 of 596 accounts on the platform have a PIN set,
/// and most outlets — including the one this terminal ships pointed at — have
/// none at all. A till must treat that as an unfinished setup rather than as
/// a failure, which is why the seeded accounts still exist as a last resort.
final List<PosStaffMember> staff;
/// 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,
role: role,
canManageStaff: canManageStaff,
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,
staff: staff,
);
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']),
role: ((json['role'] as String?) ?? '').trim(),
canManageStaff: json['can_manage_staff'] == true,
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,
staff: (json['staff'] as List<Object?>? ?? const [])
.whereType<Map<String, Object?>>()
.map(PosStaffMember.fromJson)
.where((m) => m.pin.isNotEmpty)
.toList(),
);
}
Map<String, Object?> toJson() => {
'token': token,
'expires_at': expiresAt.toIso8601String(),
'user_id': userId,
'full_name': fullName,
'email': email,
'role_id': roleId,
'role': role,
'can_manage_staff': canManageStaff,
'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(),
'staff': staff.map((m) => m.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,
};
/// One person the back office says may ring a bill at this outlet.
///
/// The PIN arrives in the clear over TLS and is hashed before it touches disk —
/// see [PosSession] and the backend's `PosStaffMember` for why hashing it
/// server-side would have bought the appearance of strength and not the
/// substance. A four-digit PIN is brute-forceable in microseconds regardless;
/// what it is, is *shift attribution* — which of the people already inside a
/// shop gets credited with a sale. The security boundary is the session token.
class PosStaffMember {
const PosStaffMember({
required this.userId,
required this.fullName,
required this.pin,
this.role = '',
});
final int userId;
final String fullName;
final String pin;
/// The back office's own role name — "Admin", "Manager", "Operations". Blank
/// for the many accounts whose `roleid` is not in `app_roles` at all.
final String role;
/// A stable local id for a row that came from the back office.
///
/// Prefixed so an imported account can be told apart from one seeded on this
/// device. That distinction is what lets a sync retire the seeded logins
/// without touching anything a shop created itself.
String get localId => 'boffice-$userId';
factory PosStaffMember.fromJson(Map<String, Object?> json) =>
PosStaffMember(
userId: _int(json['user_id']),
fullName: ((json['full_name'] as String?) ?? '').trim(),
pin: ((json['pin'] as String?) ?? '').trim(),
role: ((json['role'] as String?) ?? '').trim(),
);
Map<String, Object?> toJson() => {
'user_id': userId,
'full_name': fullName,
'pin': pin,
'role': role,
};
}