361 lines
11 KiB
Dart
361 lines
11 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;
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
/// Writes a staff member the back office owns, creating or updating the row.
|
|
///
|
|
/// Deliberately skips [_assertPinIsAcceptable] and the duplicate-PIN check.
|
|
/// Those rules exist to stop *this terminal* from accepting a weak PIN
|
|
/// someone typed at the counter; they are not this terminal's to enforce on
|
|
/// a list the back office has already published. Applying them here would
|
|
/// mean a shop whose head office issued `1111` simply never receives its
|
|
/// staff, and the till falls back to seeded demo accounts — a worse outcome
|
|
/// than a guessable PIN.
|
|
///
|
|
/// [id] is derived from the server's `user_id` rather than minted, so a
|
|
/// second sign-in updates the same row instead of duplicating the person.
|
|
///
|
|
/// A null [pin] leaves an existing PIN alone, and on a new row stores an
|
|
/// unusable hash: the person appears on the staff list and can be attributed
|
|
/// bills, but nothing typed at the keypad will ever match them. That is the
|
|
/// honest representation of "the back office did not give us their PIN".
|
|
Future<StaffUser> upsertFromServer({
|
|
required String id,
|
|
required String name,
|
|
required StaffRole role,
|
|
String? pin,
|
|
bool isActive = true,
|
|
}) async {
|
|
final trimmed = name.trim().isEmpty ? 'Staff' : name.trim();
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
final existing = await findById(id);
|
|
|
|
// Minted once and reused for both columns. PinHasher.newSalt() is random,
|
|
// so calling it twice in one statement would store a hash the stored salt
|
|
// cannot reproduce.
|
|
final salt = PinHasher.newSalt();
|
|
|
|
if (existing != null) {
|
|
await _db.update(
|
|
Tables.staff,
|
|
{
|
|
'name': trimmed,
|
|
'role': role.name,
|
|
'is_active': isActive ? 1 : 0,
|
|
if (pin != null) ...{
|
|
'pin_hash': PinHasher.hash(pin, salt),
|
|
'pin_salt': salt,
|
|
'must_change_pin': 0,
|
|
},
|
|
'updated_at': now,
|
|
},
|
|
where: 'id = ?',
|
|
whereArgs: [id],
|
|
);
|
|
return StaffUser(id: id, name: trimmed, role: role, isActive: isActive);
|
|
}
|
|
|
|
// No PIN from the server means no PIN that can ever be entered: hashing a
|
|
// random value is how that is stored, rather than a sentinel a future
|
|
// reader might treat as "any PIN accepted".
|
|
final secret = pin ?? _uuid.v4();
|
|
|
|
await _db.insert(
|
|
Tables.staff,
|
|
{
|
|
'id': id,
|
|
'name': trimmed,
|
|
'role': role.name,
|
|
'pin_hash': PinHasher.hash(secret, salt),
|
|
'pin_salt': salt,
|
|
'must_change_pin': 0,
|
|
'is_active': isActive ? 1 : 0,
|
|
'created_at': now,
|
|
'updated_at': now,
|
|
},
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
|
|
return StaffUser(id: id, name: trimmed, role: role, isActive: isActive);
|
|
}
|
|
|
|
/// A stable local id for a back-office user, so a second sign-in updates the
|
|
/// same row instead of duplicating the person.
|
|
static String serverId(int userId) => 'srv-$userId';
|
|
|
|
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,
|
|
);
|
|
}
|