From 4f9a5c3d6bfecf2d27b7a75444cfe2c503b1e330 Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 6 Aug 2026 16:02:04 +0530 Subject: [PATCH] Take staff from the back office, and let the seeded PINs die when it has any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suriya/4821, Divya/5093, Rahul/6274 were compiled into the app — the same three logins on every install, readable by anyone with the APK, and unreplaceable. Sign-in now imports the outlet's real staff and deactivates everything it didn't import, so the built-in PINs stop working the moment a shop has anyone recorded. That deactivation is the point: merging would have left the hardcoded logins alive alongside the real ones for ever. The seeds stay, and that is not a hedge. Only 116 of 596 accounts on the platform have a PIN set, and outlet 1135 — the one this build ships pointed at — has none at all. Deleting them would hand 33 of 34 tenants a till nobody can sign in to. So: back office first, local database once synced, seeds only when there is nothing else. Rows are keyed on the back office user id, so a re-sync updates one account rather than creating a second. A leaver removed upstream loses the till on the next sign-in. Accounts are deactivated rather than deleted, because bills carry the cashier's name and shifts settle against it. An import that writes nobody is treated exactly like an empty answer — a back office full of `pin = 0` rows must not deactivate the seeds and strand the counter. That is a real shape in the data, not a hypothetical. An imported PIN is not flagged for change; the shop already chose it. The flag belongs to the seeds, which everyone shares. Role names are mapped by name and fall back to cashier. `app_roles` holds six rows for four roles — Admin and Manager appear twice each — and most accounts carry a roleid absent from the table entirely, so an unrecognised role must not quietly become an admin. Co-Authored-By: Claude Opus 5 --- lib/data/local/staff_dao.dart | 94 ++++++++++++ lib/domain/entities/pos_session.dart | 63 ++++++++ .../auth/providers/auth_controller.dart | 51 +++++++ test/unit/staff_import_test.dart | 143 ++++++++++++++++++ 4 files changed, 351 insertions(+) create mode 100644 test/unit/staff_import_test.dart diff --git a/lib/data/local/staff_dao.dart b/lib/data/local/staff_dao.dart index 414d9d6..b5cc873 100644 --- a/lib/data/local/staff_dao.dart +++ b/lib/data/local/staff_dao.dart @@ -25,6 +25,10 @@ class StaffDao { final Database _db; + /// Exposed for [StaffImport], which lives in this file and is part of this + /// type in everything but syntax — an extension cannot see a private field. + Database get db => _db; + static const _uuid = Uuid(); /// The accounts a shop starts with. @@ -277,3 +281,93 @@ class StaffDao { isActive: (row['is_active'] as int? ?? 1) == 1, ); } + +/// Replaces the terminal's staff with what the back office says. +/// +/// The back office is the source of truth for who works at a shop, and this is +/// where that becomes true rather than aspirational. It exists because the +/// alternative — three names and three PINs compiled into the app — meant every +/// install of a build shared the same three logins, readable by anyone with the +/// APK. +/// +/// Three things happen, and the second is the one that matters: +/// +/// 1. every person the back office named is written, keyed on their user id so +/// a re-sync updates rather than duplicates; +/// 2. **the seeded accounts are deactivated**, so the moment a shop has real +/// staff the built-in PINs stop working — without this the hardcoded +/// logins would survive alongside the real ones for ever; and +/// 3. anyone previously imported who is no longer named is deactivated too, +/// because a leaver removed in the back office must lose the till. +/// +/// Deactivated, never deleted. Bills carry the cashier's name and shifts are +/// settled against it, so a hard delete would orphan a day's takings. +/// +/// Does nothing at all when [members] is empty. That is the common case today — +/// most outlets have no staff recorded — and wiping a working till's logins +/// because the back office has not been filled in yet would close a shop. +extension StaffImport on StaffDao { + Future replaceFromBackOffice(List members) async { + if (members.isEmpty) return 0; + + final now = DateTime.now().millisecondsSinceEpoch; + final imported = {}; + + for (final member in members) { + final pin = member.pin.trim(); + // A blank or malformed PIN cannot be signed in with. Skipped rather than + // written, so the till does not show a name nobody can use. + if (pin.length < 4 || int.tryParse(pin) == null) continue; + + final salt = PinHasher.newSalt(); + imported.add(member.localId); + + await db.insert( + Tables.staff, + { + 'id': member.localId, + 'name': member.name.isEmpty ? 'Staff ${member.localId}' : member.name, + 'role': member.role.name, + 'pin_hash': PinHasher.hash(pin, salt), + 'pin_salt': salt, + // Not flagged for change: this PIN was set by the shop in the back + // office, so it is already theirs. The flag is for the seeds. + 'must_change_pin': 0, + 'is_active': 1, + 'created_at': now, + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + // Nothing usable came back — leave the till exactly as it was rather than + // stranding it with no way to sign in. + if (imported.isEmpty) return 0; + + final placeholders = List.filled(imported.length, '?').join(','); + await db.update( + Tables.staff, + {'is_active': 0, 'updated_at': now}, + where: 'id NOT IN ($placeholders)', + whereArgs: imported.toList(), + ); + + return imported.length; + } +} + +/// One person to import, already mapped onto the till's own role vocabulary. +class StaffImportRecord { + const StaffImportRecord({ + required this.localId, + required this.name, + required this.role, + required this.pin, + }); + + final String localId; + final String name; + final StaffRole role; + final String pin; +} diff --git a/lib/domain/entities/pos_session.dart b/lib/domain/entities/pos_session.dart index 97f218b..31a7908 100644 --- a/lib/domain/entities/pos_session.dart +++ b/lib/domain/entities/pos_session.dart @@ -25,6 +25,7 @@ class PosSession { this.address = '', this.phone = '', this.outlets = const [], + this.staff = const [], }); /// The bearer token, sent on every request from here on. @@ -65,6 +66,14 @@ class PosSession { 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 staff; + /// Whether the session is still worth sending. /// /// Checked against the terminal's own clock, which is the only one available @@ -95,6 +104,7 @@ class PosSession { address: address ?? this.address, phone: phone, outlets: outlets, + staff: staff, ); factory PosSession.fromJson(Map json) { @@ -124,6 +134,11 @@ class PosSession { address: (json['address'] as String?) ?? '', phone: (json['phone'] as String?) ?? '', outlets: outlets, + staff: (json['staff'] as List? ?? const []) + .whereType>() + .map(PosStaffMember.fromJson) + .where((m) => m.pin.isNotEmpty) + .toList(), ); } @@ -143,6 +158,7 @@ class PosSession { 'address': address, 'phone': phone, 'locations': outlets.map((o) => o.toJson()).toList(), + 'staff': staff.map((m) => m.toJson()).toList(), }; } @@ -188,3 +204,50 @@ int _int(Object? value) => switch (value) { 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 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 toJson() => { + 'user_id': userId, + 'full_name': fullName, + 'pin': pin, + 'role': role, + }; +} diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index b82f127..d2e04da 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; +import '../../../data/local/staff_dao.dart'; import '../../../data/remote/pos_auth_api.dart'; import '../../../domain/entities/pos_session.dart'; import '../../../domain/entities/store_account.dart'; @@ -163,6 +164,19 @@ class AuthController extends StateNotifier { phone: session.phone, ); + // Who may ring a bill here, per the back office. + // + // This is what retires the seeded logins. The till ships with three names + // and three PINs compiled into it — the same three on every install — and + // they exist only so a shop whose back office has no staff recorded can + // still trade on day one. The moment real staff arrive they are + // deactivated, which is the whole point of importing rather than merging. + // + // Empty is the common case rather than an error: most outlets have nobody + // recorded, including the one this build ships pointed at. The import + // no-ops, the seeds survive, and the shop keeps selling. + await _importStaff(session); + _ref.invalidate(storeAccountProvider); final store = await _ref.read(storeAccountProvider.future); final staff = store.staff; @@ -184,6 +198,43 @@ class AuthController extends StateNotifier { state = Authenticated(store: store, user: opener); } + /// Writes the back office's staff over this terminal's. + /// + /// Failures are swallowed. A shop must be able to open its till even when the + /// staff import fails — the seeded or previously-synced accounts are still + /// there, and refusing the sign-in would trade a working counter for a + /// tidier database. + Future _importStaff(PosSession session) async { + if (session.staff.isEmpty) return; + + try { + await _ref.read(localStoreProvider).staff.replaceFromBackOffice([ + for (final member in session.staff) + StaffImportRecord( + localId: member.localId, + name: member.fullName, + role: _roleFor(member.role), + pin: member.pin, + ), + ]); + } on Object { + // Deliberately silent — see above. + } + } + + /// Maps the back office's role names onto the till's three. + /// + /// `app_roles` holds six rows for four distinct roles — Admin and Manager are + /// each in there twice — and most accounts carry a `roleid` that is not in + /// the table at all. So this matches on the name and falls back to the least + /// privileged answer: an unrecognised role must not silently become an admin. + StaffRole _roleFor(String backOfficeRole) => + switch (backOfficeRole.trim().toLowerCase()) { + 'super admin' || 'admin' => StaffRole.admin, + 'manager' || 'operations' => StaffRole.manager, + _ => StaffRole.cashier, + }; + /// Switches the active operator, checking their PIN. /// /// Every bill is stamped with whoever is active, so this is the boundary that diff --git a/test/unit/staff_import_test.dart b/test/unit/staff_import_test.dart new file mode 100644 index 0000000..0100307 --- /dev/null +++ b/test/unit/staff_import_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/data/datasources/local_store.dart'; +import 'package:nearle_pos/data/local/staff_dao.dart'; +import 'package:nearle_pos/domain/entities/store_account.dart'; + +/// The till shipped with three names and three PINs compiled into it — +/// Suriya/4821, Divya/5093, Rahul/6274 — identical on every install and +/// readable by anyone with the APK. They existed because a shop with nothing in +/// its back office still has to trade on day one, and that is still true: only +/// 116 of 596 accounts on the platform have a PIN, and the outlet this build +/// ships pointed at has none at all. +/// +/// So they stay, as a last resort, and these cover the thing that makes that +/// safe: real staff must *retire* them rather than sit alongside them. +StaffDao get staff => LocalStore.instance.staff; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await LocalStore.instance.reset(); + }); + + test('a fresh till has the seeded accounts and they work', () async { + final seeded = await staff.all(); + + expect(seeded, hasLength(3)); + expect(await staff.authenticate('4821'), isNotNull); + // Every one is flagged, so the first person in is made to change it. + expect(seeded.every((s) => s.mustChangePin), isTrue); + }); + + test('real staff retire the built-in PINs', () async { + // The whole point. Without this the hardcoded logins would survive next to + // the real ones for ever, on every terminal in the fleet. + await staff.replaceFromBackOffice(const [ + StaffImportRecord( + localId: 'boffice-1229', + name: 'Selvapuram', + role: StaffRole.manager, + pin: '7391', + ), + ]); + + expect(await staff.authenticate('7391'), isNotNull, + reason: 'the imported account must work'); + expect(await staff.authenticate('4821'), isNull, + reason: 'Suriya was compiled into the app and must be gone'); + expect(await staff.authenticate('5093'), isNull); + expect(await staff.authenticate('6274'), isNull); + }); + + test('an empty answer leaves a working till alone', () async { + // The common case: most outlets have nobody recorded. Wiping the logins + // because the back office has not been filled in yet would close a shop. + final written = await staff.replaceFromBackOffice(const []); + + expect(written, 0); + expect(await staff.authenticate('4821'), isNotNull); + }); + + test('a leaver loses the till on the next sign-in', () async { + await staff.replaceFromBackOffice(const [ + StaffImportRecord( + localId: 'boffice-1', name: 'Asha', role: StaffRole.cashier, pin: '7391'), + StaffImportRecord( + localId: 'boffice-2', name: 'Ravi', role: StaffRole.cashier, pin: '8402'), + ]); + expect(await staff.authenticate('8402'), isNotNull); + + // Ravi is removed in the back office. + await staff.replaceFromBackOffice(const [ + StaffImportRecord( + localId: 'boffice-1', name: 'Asha', role: StaffRole.cashier, pin: '7391'), + ]); + + expect(await staff.authenticate('7391'), isNotNull); + expect(await staff.authenticate('8402'), isNull); + }); + + test('re-syncing the same person updates rather than duplicates', () async { + // Keyed on the back office user id, so a shop that changes somebody's PIN + // gets one account with a new PIN, not two accounts with one each. + await staff.replaceFromBackOffice(const [ + StaffImportRecord( + localId: 'boffice-1', name: 'Asha', role: StaffRole.cashier, pin: '7391'), + ]); + await staff.replaceFromBackOffice(const [ + StaffImportRecord( + localId: 'boffice-1', name: 'Asha Kumar', role: StaffRole.manager, pin: '8402'), + ]); + + final all = await staff.all(); + expect(all, hasLength(1)); + expect(all.single.name, 'Asha Kumar'); + expect(all.single.role, StaffRole.manager); + expect(await staff.authenticate('8402'), isNotNull); + expect(await staff.authenticate('7391'), isNull); + }); + + test('an imported PIN is not flagged for change', () async { + // It was set by the shop in the back office, so it is already theirs. The + // flag is for the seeds, which everyone shares. + await staff.replaceFromBackOffice(const [ + StaffImportRecord( + localId: 'boffice-1', name: 'Asha', role: StaffRole.cashier, pin: '7391'), + ]); + + final imported = await staff.authenticate('7391'); + expect(imported!.mustChangePin, isFalse); + }); + + test('an unusable PIN is skipped rather than written', () async { + // A name on screen that nobody can sign in as reads as a broken terminal. + // `pin = 0` is the single most common value in app_users. + await staff.replaceFromBackOffice(const [ + StaffImportRecord( + localId: 'boffice-1', name: 'No PIN', role: StaffRole.cashier, pin: '0'), + StaffImportRecord( + localId: 'boffice-2', name: 'Blank', role: StaffRole.cashier, pin: ''), + StaffImportRecord( + localId: 'boffice-3', name: 'Usable', role: StaffRole.cashier, pin: '7391'), + ]); + + final all = await staff.all(); + expect(all, hasLength(1)); + expect(all.single.name, 'Usable'); + }); + + test('an answer of nothing usable does not strand the till', () async { + // Every row unusable is not the same as a deliberate empty list, but it has + // to behave the same way — otherwise a back office full of `pin = 0` rows + // would deactivate the seeds and leave nobody able to sign in. + final written = await staff.replaceFromBackOffice(const [ + StaffImportRecord( + localId: 'boffice-1', name: 'No PIN', role: StaffRole.cashier, pin: '0'), + ]); + + expect(written, 0); + expect(await staff.authenticate('4821'), isNotNull, + reason: 'the seeds must survive an import that wrote nobody'); + }); +}