Files
nearle_pos/lib/app/providers.dart
Suriya e17937e8f1 Move staff PINs out of the shipped binary into hashed database rows
Three StaffUser constants carried plaintext PINs (1234/2345/3456) in
auth_controller.dart. Every build shipped every till's credentials, readable
by anyone who unzipped the APK. Across 100 deployed devices that is one
credential, not a hundred.

- Schema v5 adds a staff table. Only a PBKDF2-HMAC-SHA256 hash and a per-user
  random salt are stored; the PIN itself exists nowhere, including there.
  12,000 iterations, tuned so one sign-in is imperceptible while working
  through all 10,000 four-digit PINs against a stolen database takes ~15
  minutes per account instead of milliseconds.
- Verification is constant-time. String == returns at the first differing
  byte, and that timing leaks how much of a guess was right.
- StaffUser no longer has a pin field at all, so the credential cannot drift
  back into memory, into widgets, or into a const declaration.
- Weak PINs are refused: under four digits, non-numeric, repeated digits, and
  sequences. Two staff cannot share a PIN — the till identifies a cashier by
  PIN alone, so a shared one would attribute bills to whichever row was
  checked first.
- The last admin cannot be demoted or deactivated. A till with no admin cannot
  be administered, including to appoint one, and recovering means editing the
  database by hand.
- Staff are deactivated, never deleted, so bills already rung keep naming a
  real person.

Seed accounts are now 4821/5093/6274 rather than 1234/2345/3456 — the weak-PIN
rule refuses the old ones, and a default the rule itself would reject is not a
defensible default. All three are flagged must-change-pin so they get a shop
trading on day one without becoming permanent.

Store details are now editable data, not compile-time constants. Name,
address, GSTIN and phone persist to the database and are read back rather than
falling through to the build's constants, which would silently undo a failed
save. GSTIN is format-validated including the state code — it prints on every
invoice as a legal requirement, so a typo is a compliance problem across
hundreds of bills before anyone notices.

Tests: 141 -> 160. Includes a test that reads every column of every staff row
and asserts no seed PIN appears anywhere in the database.

Migration test now asserts v5 and that an upgraded terminal comes up with the
staff table present but empty — seeding is the store's job on first open, so
an existing shop is never handed accounts it did not create.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:00:12 +05:30

207 lines
7.4 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config/sync_config.dart';
import '../core/services/connectivity_service.dart';
import '../core/services/receipt_service.dart';
import '../core/services/sound_service.dart';
import '../data/datasources/local_store.dart';
import '../data/repositories/customer_repository_impl.dart';
import '../data/repositories/product_repository_impl.dart';
import '../data/datasources/remote_catalogue_source.dart';
import '../data/remote/http_order_transport.dart';
import '../data/remote/mqtt_order_transport.dart';
import '../data/remote/order_transport.dart';
import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/store_repository_impl.dart';
import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart';
import '../data/local/terminal_identity.dart';
import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart';
import '../domain/repositories/sync_repository.dart';
import '../domain/repositories/transaction_repository.dart';
import '../domain/entities/store_account.dart';
import '../domain/usecases/checkout_sale.dart';
import '../presentation/auth/providers/auth_controller.dart';
/// Root data source. Overridden in tests with an in-memory double.
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
// ---------------------------------------------------------- Repositories
final productRepositoryProvider = Provider<ProductRepository>(
(ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
);
final customerRepositoryProvider = Provider<CustomerRepository>(
(ref) => CustomerRepositoryImpl(ref.watch(localStoreProvider)),
);
final transactionRepositoryProvider = Provider<TransactionRepository>(
(ref) => TransactionRepositoryImpl(ref.watch(localStoreProvider)),
);
/// Mirrors the Settings "Simulate offline" switch so the header can show it.
///
/// Without this the terminal claims LIVE while every call is being failed on
/// purpose, which reads as a real network fault.
final simulateOfflineProvider = StateProvider<bool>((ref) => false);
/// Simulated back-office endpoints. Held as singletons so the offline toggle
/// in Settings affects every call.
final remoteCatalogueProvider = Provider<RemoteCatalogueSource>(
(ref) => RemoteCatalogueSource(
isOffline: () => ref.read(simulateOfflineProvider),
),
);
// ------------------------------------------------------------------- Sync
/// How this terminal reaches the back office.
///
/// Defaults to the simulated route so a fresh install is usable with no broker
/// and no endpoint; Settings re-points it.
///
/// Store and terminal ids always come from this device's own identity, never
/// from a literal — two terminals publishing on the same topic is the failure
/// this exists to prevent.
final syncConfigProvider = StateProvider<SyncConfig>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return SyncConfig(
storeId: terminal.storeId,
terminalId: terminal.code,
);
});
/// Real network state, folded with the Settings offline switch.
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
final service = ConnectivityService(
isSimulatedOffline: () => ref.read(simulateOfflineProvider),
);
ref.onDispose(service.dispose);
return service;
});
/// The wire itself. Rebuilt when the configuration changes, and the old one is
/// closed so a re-pointed terminal does not keep a stale broker session open.
final orderTransportProvider = Provider<OrderTransport>((ref) {
final config = ref.watch(syncConfigProvider);
final transport = switch (config.transport) {
TransportKind.mqtt => MqttOrderTransport(config: config),
TransportKind.http => HttpOrderTransport(config: config),
TransportKind.simulated => SimulatedOrderTransport(
isOffline: () => ref.read(simulateOfflineProvider),
),
};
ref.onDispose(transport.dispose);
return transport;
});
final syncRepositoryProvider = Provider<SyncRepository>(
(ref) => SyncRepositoryImpl(
ref.watch(localStoreProvider),
ref.watch(remoteCatalogueProvider),
ref.watch(orderTransportProvider),
batchSize: ref.watch(syncConfigProvider).batchSize,
),
);
/// Decides when bills are uploaded. Started once, by the app shell.
final syncEngineProvider = Provider<SyncEngine>((ref) {
final transport = ref.watch(orderTransportProvider);
final engine = SyncEngine(
repository: ref.watch(syncRepositoryProvider),
connectivity: ref.watch(connectivityServiceProvider).onlineChanges,
downlink: transport.downlink,
onCatalogueChanged: () async {
await ref.read(syncRepositoryProvider).importCatalogue();
ref.invalidate(localStoreProvider);
},
);
ref.onDispose(engine.dispose);
return engine;
});
/// Live engine state for the header pill and the events screen.
final syncEngineStateProvider = StreamProvider<SyncEngineState>((ref) {
final engine = ref.watch(syncEngineProvider);
return engine.states.map((s) => s);
});
/// Store details and staff, read from this terminal's database.
final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
);
/// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email,
),
);
// ------------------------------------------------------------- Use cases
final checkoutSaleProvider = Provider<CheckoutSale>(
(ref) => CheckoutSale(
productRepository: ref.watch(productRepositoryProvider),
customerRepository: ref.watch(customerRepositoryProvider),
transactionRepository: ref.watch(transactionRepositoryProvider),
),
);
// -------------------------------------------------------------- Services
final soundServiceProvider =
Provider<SoundService>((ref) => SoundService.instance);
final receiptServiceProvider =
Provider<ReceiptService>((ref) => ReceiptService.instance);
// --------------------------------------------------------------- Session
class CashierSession {
const CashierSession({
required this.name,
required this.role,
required this.terminalId,
});
final String name;
final String role;
final String terminalId;
}
/// Identity of the physical till, read from its own database.
///
/// Falls back only before the store has opened; every real read happens after
/// `LocalStore.init`, which mints the identity if this device has never run
/// before.
final terminalIdentityProvider = Provider<TerminalIdentity>((ref) {
final store = ref.watch(localStoreProvider);
return store.isReady
? store.terminal
: const TerminalIdentity(
deviceId: 'unopened',
code: 'T0000',
name: 'Terminal',
storeId: 'store-01',
);
});
final cashierSessionProvider = StateProvider<CashierSession>((ref) {
final terminal = ref.watch(terminalIdentityProvider);
return CashierSession(
name: 'Suriya',
role: 'ADMIN',
terminalId: terminal.code,
);
});
/// Ticks once a minute to drive the header clock without rebuilding on every
/// frame.
final clockProvider = StreamProvider<DateTime>((ref) async* {
yield DateTime.now();
yield* Stream.periodic(const Duration(seconds: 20), (_) => DateTime.now());
});