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>
453 lines
16 KiB
Dart
453 lines
16 KiB
Dart
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';
|
|
|
|
/// Sign-in state for the terminal.
|
|
sealed class AuthState {
|
|
const AuthState();
|
|
|
|
bool get isAuthenticated => this is Authenticated;
|
|
}
|
|
|
|
class Unauthenticated extends AuthState {
|
|
const Unauthenticated();
|
|
}
|
|
|
|
class Authenticating extends AuthState {
|
|
const Authenticating();
|
|
}
|
|
|
|
class Authenticated extends AuthState {
|
|
const Authenticated({
|
|
required this.store,
|
|
required this.user,
|
|
required this.login,
|
|
});
|
|
|
|
final StoreAccount store;
|
|
final StaffUser user;
|
|
|
|
/// What this session may open. The authority on what the terminal shows —
|
|
/// not [user], which can be swapped at the till without re-authenticating.
|
|
final TerminalLogin login;
|
|
|
|
StaffRole get role => login.role;
|
|
|
|
bool get isAdmin => login == TerminalLogin.admin;
|
|
bool get isCashier => login == TerminalLogin.cashier;
|
|
}
|
|
|
|
class AuthFailure extends AuthState {
|
|
const AuthFailure(this.message);
|
|
|
|
final String message;
|
|
}
|
|
|
|
/// What a signed-in account may do with this terminal.
|
|
///
|
|
/// Two shapes, because the terminal only ever behaves in two ways, and the
|
|
/// split is what the roles are *for* rather than decoration:
|
|
///
|
|
/// * [admin] runs the whole shell and is the only login that can pull the
|
|
/// catalogue. Signing out leaves the products on the terminal.
|
|
/// * [cashier] gets the billing screen and nothing else, and signing out
|
|
/// takes the catalogue with it.
|
|
///
|
|
/// This used to carry an email and a password per entry, and the terminal
|
|
/// decided which role you were by comparing what you typed against those
|
|
/// constants. That made the role a property of the *build* — every install
|
|
/// shared two logins, and a shop could not add a third person or revoke the
|
|
/// two it had without shipping a new APK.
|
|
///
|
|
/// The role now arrives from the back office as a property of the *account*.
|
|
/// [forSession] is the only way to construct one, so there is no path left
|
|
/// where the terminal grants itself a permission the server did not send.
|
|
enum TerminalLogin {
|
|
admin(
|
|
label: 'Supervisor',
|
|
role: StaffRole.admin,
|
|
blurb: 'Full shell — import products, promos, staff, settings.',
|
|
),
|
|
cashier(
|
|
label: 'Cashier',
|
|
role: StaffRole.cashier,
|
|
blurb: 'Billing only, on the products the supervisor imported.',
|
|
);
|
|
|
|
const TerminalLogin({
|
|
required this.label,
|
|
required this.role,
|
|
required this.blurb,
|
|
});
|
|
|
|
final String label;
|
|
final StaffRole role;
|
|
final String blurb;
|
|
|
|
/// The catalogue is pulled once by a supervisor and billed against by
|
|
/// whoever is on the counter, so only the cashier's sign-out drops it. A
|
|
/// supervisor closing the shell is a handover, not the end of the day.
|
|
bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier;
|
|
|
|
/// Which shell the back office says this account gets.
|
|
///
|
|
/// Reads `can_manage_staff` rather than the role name or id. The name is
|
|
/// free text and often blank, and `app_roles` holds six rows for four
|
|
/// distinct roles with a great many accounts carrying a `roleid` that is not
|
|
/// in the table at all — so matching on either here would mean keeping a
|
|
/// copy of the role table in the app and keeping the two in step for ever.
|
|
/// One boolean, decided by the server, cannot drift.
|
|
static TerminalLogin forSession(PosSession session) =>
|
|
session.canManageStaff ? TerminalLogin.admin : TerminalLogin.cashier;
|
|
}
|
|
|
|
/// Signs the terminal in against the back office and holds the session.
|
|
///
|
|
/// This used to compare against constants compiled into the app, with a 600ms
|
|
/// delay standing in for a network call that was never made. Two things were
|
|
/// wrong with that, and the second was the serious one:
|
|
///
|
|
/// 1. every install of a build shared one password, and changing it meant a
|
|
/// rebuild; and
|
|
/// 2. because nothing was checked with the back office, the *outlet* could not
|
|
/// come from the sign-in. It came from a store id typed into Settings — so
|
|
/// a till named its own shop and was believed, and one number changed on
|
|
/// one screen moved a terminal into another tenant's books.
|
|
///
|
|
/// Now a person signs in with their own back-office account, and both the
|
|
/// outlet and the role arrive as a consequence: sealed in a signed token,
|
|
/// checked server-side on every request, and not editable from this device.
|
|
class AuthController extends StateNotifier<AuthState> {
|
|
AuthController(this._ref) : super(const Unauthenticated());
|
|
|
|
final Ref _ref;
|
|
|
|
/// The back office's answer to the last sign-in, if there is one.
|
|
///
|
|
/// Held so the outlet picker can offer a proprietor their other shops without
|
|
/// asking for the password a second time.
|
|
PosSession? _session;
|
|
PosSession? get session => _session;
|
|
|
|
/// Whether signing out right now would wipe the products off this terminal.
|
|
///
|
|
/// Read *before* [signOut] by anything that needs to warn the operator, since
|
|
/// the session is gone by the time it returns.
|
|
bool get clearsCatalogueOnSignOut {
|
|
final current = state;
|
|
return current is Authenticated && current.login.clearsCatalogueOnSignOut;
|
|
}
|
|
|
|
/// Restores a session saved on a previous run.
|
|
///
|
|
/// Called at start-up so a till that was rebooted mid-shift comes back
|
|
/// trading rather than showing a login screen to a queue of customers.
|
|
/// Returns false when there is nothing usable, which includes an expired
|
|
/// session — [SessionStore] treats those as absent.
|
|
Future<bool> restore() async {
|
|
final saved = await _ref.read(sessionStoreProvider).read();
|
|
if (saved == null) return false;
|
|
|
|
await _adopt(saved);
|
|
return state is Authenticated;
|
|
}
|
|
|
|
Future<bool> signIn({
|
|
required String email,
|
|
required String password,
|
|
int? locationId,
|
|
}) async {
|
|
state = const Authenticating();
|
|
|
|
final terminal = _ref.read(terminalIdentityProvider);
|
|
|
|
final PosSession session;
|
|
try {
|
|
session = await _ref.read(posAuthApiProvider).login(
|
|
authname: email,
|
|
password: password,
|
|
terminalId: terminal.code,
|
|
deviceId: terminal.deviceId,
|
|
locationId: locationId,
|
|
);
|
|
} on PosAuthException catch (e) {
|
|
state = AuthFailure(e.message);
|
|
return false;
|
|
} on Object {
|
|
state = const AuthFailure(
|
|
'Sign-in failed for an unexpected reason. Please try again.',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
await _ref.read(sessionStoreProvider).write(session);
|
|
await _adopt(session);
|
|
|
|
return state is Authenticated;
|
|
}
|
|
|
|
/// Moves this terminal to another of the signed-in account's outlets.
|
|
///
|
|
/// A fresh sign-in rather than a local switch, because the outlet is inside
|
|
/// the signed token: the back office has to issue a new one, and re-checking
|
|
/// entitlement at that moment is the point. Requires the password again,
|
|
/// which is correct — moving a till between shops changes whose books it
|
|
/// writes to.
|
|
Future<bool> switchOutlet({
|
|
required String password,
|
|
required int locationId,
|
|
}) async {
|
|
final current = _session;
|
|
if (current == null) return false;
|
|
|
|
return signIn(
|
|
email: current.email.isNotEmpty ? current.email : current.fullName,
|
|
password: password,
|
|
locationId: locationId,
|
|
);
|
|
}
|
|
|
|
/// Adopts a session: points the terminal at its outlet, then opens it.
|
|
///
|
|
/// Order matters. The store id and token are written *before* the catalogue
|
|
/// or any uplink can run, so a terminal can never spend even one request
|
|
/// pointed at the outlet it had yesterday while claiming to be signed in as
|
|
/// today's.
|
|
Future<void> _adopt(PosSession session) async {
|
|
_session = session;
|
|
|
|
await _ref.read(localStoreProvider).identityStore.rename(
|
|
storeId: session.storeId,
|
|
);
|
|
_ref.invalidate(terminalIdentityProvider);
|
|
|
|
_ref.read(syncConfigProvider.notifier).state =
|
|
_ref.read(syncConfigProvider).copyWith(
|
|
storeId: session.storeId,
|
|
sessionToken: session.token,
|
|
);
|
|
|
|
// Store details for the receipt come from the back office now, not from
|
|
// constants compiled into the build. A GSTIN is a legal requirement on a
|
|
// tax invoice; it should not need a rebuild to correct.
|
|
await _ref.read(storeRepositoryProvider).save(
|
|
name: session.locationName.isNotEmpty
|
|
? session.locationName
|
|
: session.tenantName,
|
|
address: session.address,
|
|
gstin: session.gstin,
|
|
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;
|
|
|
|
if (staff.isEmpty) {
|
|
state = const AuthFailure(
|
|
'This terminal has no staff accounts. Reinstall to seed them.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
state = Authenticated(
|
|
store: store,
|
|
user: _opener(staff, session),
|
|
login: TerminalLogin.forSession(session),
|
|
);
|
|
}
|
|
|
|
/// Who the terminal attributes bills to the moment it opens.
|
|
///
|
|
/// The person who just signed in, if the import wrote them — matched on the
|
|
/// back office id rather than the name, which is neither unique nor stable.
|
|
/// Falls back to anyone rather than failing: the session's permissions come
|
|
/// from the token, so a shop whose staff list is empty or unsynced still gets
|
|
/// a usable till, and the bills are simply stamped with the account that is
|
|
/// there until someone switches with their PIN.
|
|
StaffUser _opener(List<StaffUser> staff, PosSession session) {
|
|
final mine = 'boffice-${session.userId}';
|
|
for (final s in staff) {
|
|
if (s.id == mine) return s;
|
|
}
|
|
|
|
final wanted = TerminalLogin.forSession(session).role;
|
|
return staff.firstWhere((s) => s.role == wanted, orElse: () => staff.first);
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Supervisor is the role a shop actually hands out; it sits with Admin
|
|
/// because a supervisor *is* the till's administrator.
|
|
StaffRole _roleFor(String backOfficeRole) =>
|
|
switch (backOfficeRole.trim().toLowerCase()) {
|
|
'super admin' || 'admin' || 'supervisor' => 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
|
|
/// decides who a sale is attributed to — it cannot be a bare selection from a
|
|
/// list. It changes who the bill names, never what the session may open:
|
|
/// [Authenticated.login] is untouched, so a cashier terminal stays a cashier
|
|
/// terminal.
|
|
///
|
|
/// That is deliberate. A PIN is four digits typed at an unattended counter;
|
|
/// it is shift attribution, not a privilege boundary. Escalating to the full
|
|
/// shell takes a real sign-in, because that is the only thing the back office
|
|
/// sees and signs.
|
|
Future<bool> switchUser(String pin) async {
|
|
final current = state;
|
|
if (current is! Authenticated) return false;
|
|
|
|
final store = _ref.read(localStoreProvider);
|
|
final user = await store.staff.authenticate(pin);
|
|
if (user == null) return false;
|
|
|
|
state = Authenticated(
|
|
store: current.store,
|
|
user: user,
|
|
login: current.login,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/// Re-reads the store after staff or details change, keeping the session.
|
|
Future<void> refreshStore() async {
|
|
final current = state;
|
|
if (current is! Authenticated) return;
|
|
|
|
_ref.invalidate(storeAccountProvider);
|
|
final store = await _ref.read(storeAccountProvider.future);
|
|
|
|
final me = store.staff.where((s) => s.id == current.user.id);
|
|
state = Authenticated(
|
|
store: store,
|
|
// Signed out if the active operator was just deactivated — carrying on
|
|
// would keep stamping bills with an account the shop has revoked.
|
|
user: me.isEmpty ? store.staff.first : me.first,
|
|
login: current.login,
|
|
);
|
|
}
|
|
|
|
/// Ends the session, and — for a cashier only — the catalogue with it.
|
|
///
|
|
/// The token always goes, from the keystore and from the live configuration
|
|
/// both. Leaving it in place would let a signed-out terminal keep uploading
|
|
/// as the shop that signed in this morning, so that half is a security
|
|
/// matter and takes no exception.
|
|
///
|
|
/// The catalogue is a workflow matter and does take one. Every way out of a
|
|
/// cashier session clears the products — ending a shift and a temporary
|
|
/// logout alike — because the terminal is left unattended either way and the
|
|
/// next session should bill against what the back office answers with rather
|
|
/// than a catalogue carried over. A supervisor signing out is the opposite
|
|
/// case: they have just pulled the products *so that* a cashier can pick the
|
|
/// terminal up, so dropping the table here would make the import pointless.
|
|
Future<void> signOut() async {
|
|
if (clearsCatalogueOnSignOut) {
|
|
await _ref.read(localStoreProvider).clearCatalogue();
|
|
}
|
|
|
|
await _ref.read(sessionStoreProvider).clear();
|
|
|
|
_ref.read(syncConfigProvider.notifier).state =
|
|
_ref.read(syncConfigProvider).copyWith(sessionToken: '');
|
|
|
|
_session = null;
|
|
state = const Unauthenticated();
|
|
}
|
|
|
|
void clearError() {
|
|
if (state is AuthFailure) state = const Unauthenticated();
|
|
}
|
|
}
|
|
|
|
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
|
|
AuthController.new,
|
|
);
|
|
|
|
/// The signed-in store, or null before sign-in.
|
|
final currentStoreProvider = Provider<StoreAccount?>((ref) {
|
|
final s = ref.watch(authControllerProvider);
|
|
return s is Authenticated ? s.store : null;
|
|
});
|
|
|
|
/// The active operator, or null before sign-in.
|
|
final currentUserProvider = Provider<StaffUser?>((ref) {
|
|
final s = ref.watch(authControllerProvider);
|
|
return s is Authenticated ? s.user : null;
|
|
});
|
|
|
|
/// Which credential is holding this session open, or null before sign-in.
|
|
final terminalLoginProvider = Provider<TerminalLogin?>((ref) {
|
|
final s = ref.watch(authControllerProvider);
|
|
return s is Authenticated ? s.login : null;
|
|
});
|
|
|
|
/// True when the terminal is locked down to the billing screen.
|
|
///
|
|
/// The one flag the shell reads: no sidebar, no back-office modules, sign-out
|
|
/// and events promoted to the header.
|
|
final isCashierModeProvider = Provider<bool>(
|
|
(ref) => ref.watch(terminalLoginProvider) == TerminalLogin.cashier,
|
|
);
|
|
|
|
final isAdminModeProvider = Provider<bool>(
|
|
(ref) => ref.watch(terminalLoginProvider) == TerminalLogin.admin,
|
|
);
|
|
|
|
/// True while anyone is still on a seeded or admin-reset PIN.
|
|
final mustChangePinProvider = Provider<bool>((ref) {
|
|
final user = ref.watch(currentUserProvider);
|
|
return user?.mustChangePin ?? false;
|
|
});
|