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 { 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 restore() async { final saved = await _ref.read(sessionStoreProvider).read(); if (saved == null) return false; await _adopt(saved); return state is Authenticated; } Future 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 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 _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 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 _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 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 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 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.new, ); /// The signed-in store, or null before sign-in. final currentStoreProvider = Provider((ref) { final s = ref.watch(authControllerProvider); return s is Authenticated ? s.store : null; }); /// The active operator, or null before sign-in. final currentUserProvider = Provider((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((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( (ref) => ref.watch(terminalLoginProvider) == TerminalLogin.cashier, ); final isAdminModeProvider = Provider( (ref) => ref.watch(terminalLoginProvider) == TerminalLogin.admin, ); /// True while anyone is still on a seeded or admin-reset PIN. final mustChangePinProvider = Provider((ref) { final user = ref.watch(currentUserProvider); return user?.mustChangePin ?? false; });