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}); final StoreAccount store; final StaffUser user; } class AuthFailure extends AuthState { const AuthFailure(this.message); final String message; } /// Signs the terminal in against the back office and holds the session. /// /// This used to compare against two constants compiled into the app — /// `admin@nearle.in` / `nearle123` — 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 the outlet /// arrives 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; /// 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; } // The first admin, or whoever is there. A person switches to their own // account at the till. final opener = staff.firstWhere( (s) => s.role == StaffRole.admin, orElse: () => staff.first, ); state = Authenticated(store: store, user: opener); } /// 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. StaffRole _roleFor(String backOfficeRole) => switch (backOfficeRole.trim().toLowerCase()) { 'super admin' || 'admin' => 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. 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); 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, ); } /// The next shift should only ever bill against what the back office /// answers with, never a catalogue instance carried over from this /// session — so the local product table is dropped before the session /// itself is. /// /// The token goes with it, 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. Future signOut() async { 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; }); /// 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; });