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>
This commit is contained in:
@@ -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<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user