Take staff from the back office, and let the seeded PINs die when it has any

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 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-06 16:02:04 +05:30
parent b5b2047bcd
commit 4f9a5c3d6b
4 changed files with 351 additions and 0 deletions

View File

@@ -25,6 +25,10 @@ class StaffDao {
final Database _db; 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(); static const _uuid = Uuid();
/// The accounts a shop starts with. /// The accounts a shop starts with.
@@ -277,3 +281,93 @@ class StaffDao {
isActive: (row['is_active'] as int? ?? 1) == 1, 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<int> replaceFromBackOffice(List<StaffImportRecord> members) async {
if (members.isEmpty) return 0;
final now = DateTime.now().millisecondsSinceEpoch;
final imported = <String>{};
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;
}

View File

@@ -25,6 +25,7 @@ class PosSession {
this.address = '', this.address = '',
this.phone = '', this.phone = '',
this.outlets = const [], this.outlets = const [],
this.staff = const [],
}); });
/// The bearer token, sent on every request from here on. /// The bearer token, sent on every request from here on.
@@ -65,6 +66,14 @@ class PosSession {
bool get hasChoiceOfOutlet => outlets.length > 1; 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. /// Whether the session is still worth sending.
/// ///
/// Checked against the terminal's own clock, which is the only one available /// Checked against the terminal's own clock, which is the only one available
@@ -95,6 +104,7 @@ class PosSession {
address: address ?? this.address, address: address ?? this.address,
phone: phone, phone: phone,
outlets: outlets, outlets: outlets,
staff: staff,
); );
factory PosSession.fromJson(Map<String, Object?> json) { factory PosSession.fromJson(Map<String, Object?> json) {
@@ -124,6 +134,11 @@ class PosSession {
address: (json['address'] as String?) ?? '', address: (json['address'] as String?) ?? '',
phone: (json['phone'] as String?) ?? '', phone: (json['phone'] as String?) ?? '',
outlets: outlets, outlets: outlets,
staff: (json['staff'] as List<Object?>? ?? const [])
.whereType<Map<String, Object?>>()
.map(PosStaffMember.fromJson)
.where((m) => m.pin.isNotEmpty)
.toList(),
); );
} }
@@ -143,6 +158,7 @@ class PosSession {
'address': address, 'address': address,
'phone': phone, 'phone': phone,
'locations': outlets.map((o) => o.toJson()).toList(), '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, final String v => int.tryParse(v.trim()) ?? 0,
_ => 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,
};
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.dart'; import '../../../app/providers.dart';
import '../../../data/local/staff_dao.dart';
import '../../../data/remote/pos_auth_api.dart'; import '../../../data/remote/pos_auth_api.dart';
import '../../../domain/entities/pos_session.dart'; import '../../../domain/entities/pos_session.dart';
import '../../../domain/entities/store_account.dart'; import '../../../domain/entities/store_account.dart';
@@ -163,6 +164,19 @@ class AuthController extends StateNotifier<AuthState> {
phone: session.phone, 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); _ref.invalidate(storeAccountProvider);
final store = await _ref.read(storeAccountProvider.future); final store = await _ref.read(storeAccountProvider.future);
final staff = store.staff; final staff = store.staff;
@@ -184,6 +198,43 @@ class AuthController extends StateNotifier<AuthState> {
state = Authenticated(store: store, user: opener); 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<void> _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. /// Switches the active operator, checking their PIN.
/// ///
/// Every bill is stamped with whoever is active, so this is the boundary that /// Every bill is stamped with whoever is active, so this is the boundary that

View File

@@ -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');
});
}