Files
nearle_pos/lib/data/local/staff_dao.dart
Suriya 4f9a5c3d6b 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>
2026-08-06 16:02:04 +05:30

374 lines
12 KiB
Dart

import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
import '../../core/security/pin_hasher.dart';
import '../../domain/entities/store_account.dart';
import 'app_database.dart';
/// Raised when a staff change would leave the terminal unusable or unowned.
class StaffException implements Exception {
const StaffException(this.message);
final String message;
@override
String toString() => message;
}
/// Who can sign in at this till.
///
/// The PIN is never stored, only a PBKDF2 hash and its salt — so a stolen
/// database file does not hand over the terminal, and neither does an unzipped
/// APK, which is what the previous hardcoded literals did.
class StaffDao {
const StaffDao(this._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();
/// The accounts a shop starts with.
///
/// Deliberately not 1234/2345/3456: those are the first thing anyone tries,
/// and [_assertPinIsAcceptable] refuses them for exactly that reason — a
/// seed the rule itself would reject is not a defensible default.
///
/// They are still known values in source, which is why every one is flagged
/// [StaffUser.mustChangePin]. They get a shop trading on day one and are
/// replaced at first sign-in, rather than becoming the permanent credentials
/// the way the old hardcoded PINs did.
static const seedAccounts = [
(name: 'Suriya', role: StaffRole.admin, pin: '4821'),
(name: 'Divya', role: StaffRole.manager, pin: '5093'),
(name: 'Rahul', role: StaffRole.cashier, pin: '6274'),
];
/// Creates the starting accounts the first time a terminal runs.
///
/// Idempotent: a terminal that already has staff is left alone, so an upgrade
/// never resurrects a deleted account or resets a PIN someone chose.
Future<void> seedIfEmpty() async {
final existing = await _db.rawQuery(
'SELECT COUNT(*) AS c FROM ${Tables.staff}',
);
if ((existing.first['c']! as int) > 0) return;
for (final account in seedAccounts) {
await create(
name: account.name,
role: account.role,
pin: account.pin,
mustChangePin: true,
);
}
}
Future<List<StaffUser>> all({bool includeInactive = false}) async {
final rows = await _db.query(
Tables.staff,
where: includeInactive ? null : 'is_active = 1',
orderBy: 'created_at ASC',
);
return rows.map(_fromRow).toList();
}
Future<StaffUser?> findById(String id) async {
final rows = await _db.query(
Tables.staff,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return rows.isEmpty ? null : _fromRow(rows.first);
}
/// Checks a PIN and returns whose it is.
///
/// Every active account is tried, because a cashier types only a PIN — there
/// is no username at the till. Returns null on no match, without saying
/// whether the PIN was close.
Future<StaffUser?> authenticate(String pin) async {
final rows = await _db.query(Tables.staff, where: 'is_active = 1');
for (final row in rows) {
final matches = PinHasher.verify(
pin,
salt: row['pin_salt']! as String,
hash: row['pin_hash']! as String,
);
if (matches) return _fromRow(row);
}
return null;
}
Future<StaffUser> create({
required String name,
required StaffRole role,
required String pin,
bool mustChangePin = false,
}) async {
_assertPinIsAcceptable(pin);
final trimmed = name.trim();
if (trimmed.isEmpty) {
throw const StaffException('A staff member needs a name.');
}
// Two people sharing a PIN would make the till attribute bills to whichever
// row happened to be checked first.
if (await authenticate(pin) != null) {
throw const StaffException(
'Another staff member already uses that PIN. Choose a different one.',
);
}
final salt = PinHasher.newSalt();
final now = DateTime.now().millisecondsSinceEpoch;
final id = _uuid.v4();
await _db.insert(Tables.staff, {
'id': id,
'name': trimmed,
'role': role.name,
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
'must_change_pin': mustChangePin ? 1 : 0,
'is_active': 1,
'created_at': now,
'updated_at': now,
});
return StaffUser(
id: id,
name: trimmed,
role: role,
mustChangePin: mustChangePin,
);
}
Future<void> updateDetails({
required String id,
String? name,
StaffRole? role,
}) async {
if (role != null) await _assertNotLastAdmin(id, newRole: role);
await _db.update(
Tables.staff,
{
if (name != null) 'name': name.trim(),
if (role != null) 'role': role.name,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
/// Sets a new PIN. [mustChangePin] is for an admin resetting someone else's;
/// a person choosing their own clears the flag.
Future<void> setPin(
String id,
String pin, {
bool mustChangePin = false,
}) async {
_assertPinIsAcceptable(pin);
final owner = await authenticate(pin);
if (owner != null && owner.id != id) {
throw const StaffException(
'Another staff member already uses that PIN. Choose a different one.',
);
}
final salt = PinHasher.newSalt();
await _db.update(
Tables.staff,
{
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
'must_change_pin': mustChangePin ? 1 : 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
/// Deactivates rather than deletes.
///
/// Bills carry the cashier's name, and reports are settled against it. A hard
/// delete would leave yesterday's takings attributed to nobody.
Future<void> deactivate(String id) async {
await _assertNotLastAdmin(id, deactivating: true);
await _db.update(
Tables.staff,
{
'is_active': 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> reactivate(String id) async {
await _db.update(
Tables.staff,
{
'is_active': 1,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
// ------------------------------------------------------------- Internals
static void _assertPinIsAcceptable(String pin) {
if (pin.length < 4 || int.tryParse(pin) == null) {
throw const StaffException('A PIN must be at least four digits.');
}
// Not security theatre: on a keypad behind a counter these are the ones a
// queue can read off the operator's hand.
const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666',
'7777', '8888', '9999', '1234', '4321', '0123',};
if (tooObvious.contains(pin)) {
throw const StaffException(
'That PIN is too easy to guess from across the counter. '
'Choose another.',
);
}
}
/// A till with no admin cannot be administered — including to make someone an
/// admin again. Recovering from it means editing the database by hand.
Future<void> _assertNotLastAdmin(
String id, {
StaffRole? newRole,
bool deactivating = false,
}) async {
final target = await findById(id);
if (target == null || target.role != StaffRole.admin) return;
final losingAdmin = deactivating || (newRole != StaffRole.admin);
if (!losingAdmin) return;
final admins = await _db.rawQuery(
'SELECT COUNT(*) AS c FROM ${Tables.staff} '
"WHERE role = 'admin' AND is_active = 1",
);
if ((admins.first['c']! as int) <= 1) {
throw const StaffException(
'This is the only admin left. Promote someone else first, or the '
'terminal cannot be administered at all.',
);
}
}
static StaffUser _fromRow(Map<String, Object?> row) => StaffUser(
id: row['id']! as String,
name: row['name']! as String,
role: StaffRole.values.byName(row['role']! as String),
mustChangePin: (row['must_change_pin'] as int? ?? 0) == 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;
}