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:
Suriya
2026-08-07 11:06:00 +05:30
parent eebd10da6d
commit 829e5a8188
16 changed files with 994 additions and 325 deletions

View File

@@ -17,7 +17,9 @@ import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/store_repository_impl.dart'; import '../data/repositories/store_repository_impl.dart';
import '../data/repositories/sync_repository_impl.dart'; import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart'; import '../data/repositories/transaction_repository_impl.dart';
import '../data/local/session_store.dart';
import '../data/local/terminal_identity.dart'; import '../data/local/terminal_identity.dart';
import '../data/remote/pos_auth_api.dart';
import '../data/sync/sync_engine.dart'; import '../data/sync/sync_engine.dart';
import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart'; import '../domain/repositories/product_repository.dart';
@@ -161,10 +163,28 @@ final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)), (ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
); );
/// Signs a terminal in against the back office.
///
/// Points at the same base URL the uplinks use, so re-pointing a terminal in
/// Settings moves its sign-in with it rather than leaving it authenticating
/// against the endpoint it used to belong to.
final posAuthApiProvider = Provider<PosAuthApi>((ref) {
final api = PosAuthApi(baseUrl: ref.watch(syncConfigProvider).httpBaseUrl);
ref.onDispose(api.dispose);
return api;
});
/// Where the signed session survives a restart.
final sessionStoreProvider = Provider<SessionStore>((ref) => SessionStore());
/// The outlet, refreshed whenever staff or details change. /// The outlet, refreshed whenever staff or details change.
///
/// The email is the signed-in account's, not a constant. It used to be a
/// `DemoCredentials.email` compiled into the build — the same address on every
/// install, which is what made the store login decorative.
final storeAccountProvider = FutureProvider<StoreAccount>( final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load( (ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email, email: ref.watch(authControllerProvider.notifier).session?.email ?? '',
), ),
); );

View File

@@ -35,6 +35,7 @@ class SyncConfig {
this.password, this.password,
this.httpBaseUrl = '', this.httpBaseUrl = '',
this.apiKey, this.apiKey,
this.sessionToken,
this.ackTimeout = const Duration(seconds: 20), this.ackTimeout = const Duration(seconds: 20),
this.batchSize = 50, this.batchSize = 50,
}); });
@@ -52,8 +53,38 @@ class SyncConfig {
final String? password; final String? password;
final String httpBaseUrl; final String httpBaseUrl;
/// A static key shared by every terminal at a deployment, if one is set.
///
/// Predates sign-in and says nothing about *who* is at the till, so it cannot
/// scope a request to an outlet. Kept for deployments that put one in front
/// of the endpoint.
final String? apiKey; final String? apiKey;
/// The signed session from `POST /login`, held for the trading day.
///
/// Distinct from [apiKey] because the two answer different questions. The key
/// says "this request came from our fleet"; the session says "this request
/// came from Selvapuram, signed in as Ragul, and may touch that outlet and no
/// other". Only the second can stop a till reaching another tenant's books,
/// which is why it takes precedence when both are present.
final String? sessionToken;
/// What goes in the Authorization header.
///
/// One accessor rather than the same `??` repeated at each call site, because
/// the request that forgot it would be the one silently sending no
/// credentials at all.
String? get bearerToken {
final session = sessionToken?.trim();
if (session != null && session.isNotEmpty) return session;
final key = apiKey?.trim();
if (key != null && key.isNotEmpty) return key;
return null;
}
/// How long to wait for the back office to confirm a batch before treating /// How long to wait for the back office to confirm a batch before treating
/// the outcome as unknown and leaving every row pending. /// the outcome as unknown and leaving every row pending.
/// ///
@@ -148,6 +179,7 @@ class SyncConfig {
String? password, String? password,
String? httpBaseUrl, String? httpBaseUrl,
String? apiKey, String? apiKey,
String? sessionToken,
Duration? ackTimeout, Duration? ackTimeout,
int? batchSize, int? batchSize,
}) => }) =>
@@ -162,6 +194,7 @@ class SyncConfig {
password: password ?? this.password, password: password ?? this.password,
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl, httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
apiKey: apiKey ?? this.apiKey, apiKey: apiKey ?? this.apiKey,
sessionToken: sessionToken ?? this.sessionToken,
ackTimeout: ackTimeout ?? this.ackTimeout, ackTimeout: ackTimeout ?? this.ackTimeout,
batchSize: batchSize ?? this.batchSize, batchSize: batchSize ?? this.batchSize,
); );

View File

@@ -0,0 +1,81 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../domain/entities/pos_session.dart';
/// Keeps a terminal signed in across restarts.
///
/// A till is not a browser. It signs in when a shop opens and bills for the
/// whole trading day, often on a connection that comes and goes, and it must
/// survive being rebooted mid-shift without a queue of customers waiting while
/// somebody finds the manager's password.
///
/// The whole session goes to the platform keystore rather than to SQLite. The
/// token is a bearer credential — anything holding it can bill as this shop —
/// and SQLite here is a file on a machine behind a shop counter, readable by
/// anything that can open it. The rest of the session travels with the token
/// because splitting them invites the two halves to disagree about which outlet
/// this terminal is.
class SessionStore {
SessionStore({FlutterSecureStorage? secureStorage})
: _secure = secureStorage ?? const FlutterSecureStorage();
final FlutterSecureStorage _secure;
static const _key = 'pos.session';
/// Reads the saved session, or null when there is none worth using.
///
/// An expired session is treated as absent rather than returned for the
/// caller to check. Every caller would have to make the same check, and the
/// one that forgot would send a dead token all day and read the resulting
/// 401s as a server fault.
Future<PosSession?> read({DateTime? now}) async {
final String? raw;
try {
raw = await _secure.read(key: _key);
} on Object catch (e) {
// No keystore — a headless test host, or a Linux box with no secret
// service. Signing in again is the safe way to fail.
debugPrint('Secure storage unavailable, session not loaded: $e');
return null;
}
if (raw == null || raw.isEmpty) return null;
try {
final session =
PosSession.fromJson(jsonDecode(raw) as Map<String, Object?>);
if (!session.isValidAt(now ?? DateTime.now())) return null;
if (session.token.isEmpty || session.locationId <= 0) return null;
return session;
} on Object catch (e) {
// A stored session this build cannot parse — most likely written by an
// older one. Dropped rather than repaired: a half-understood session is
// worse than none, and re-authenticating costs one screen.
debugPrint('Stored session could not be read, discarding: $e');
return null;
}
}
Future<void> write(PosSession session) async {
try {
await _secure.write(key: _key, value: jsonEncode(session.toJson()));
} on Object catch (e) {
// The terminal keeps working on the session it holds in memory; it just
// will not survive a restart. Failing the sign-in over this would close a
// shop for a keystore problem.
debugPrint('Could not persist the session: $e');
}
}
Future<void> clear() async {
try {
await _secure.delete(key: _key);
} on Object catch (e) {
debugPrint('Could not clear the session: $e');
}
}
}

View File

@@ -25,6 +25,10 @@ class StaffDao {
final Database _db; 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(); static const _uuid = Uuid();
/// The accounts a shop starts with. /// The accounts a shop starts with.
@@ -277,3 +281,93 @@ class StaffDao {
isActive: (row['is_active'] as int? ?? 1) == 1, 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;
}

View File

@@ -0,0 +1,149 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../domain/entities/pos_session.dart';
/// Raised when the back office refuses or cannot answer a sign-in.
///
/// Carries a message meant to be shown to whoever is standing at the till, so
/// it is written for them rather than for a log: what happened, and what they
/// can do about it.
class PosAuthException implements Exception {
const PosAuthException(this.message, {this.isCredentialFailure = false});
final String message;
/// Whether the details were wrong, as opposed to the back office being
/// unreachable. The till reacts differently: a bad password is worth
/// re-typing, an unreachable server is worth waiting for.
final bool isCredentialFailure;
@override
String toString() => message;
}
/// Signs a terminal in against the back office.
///
/// Talks to the same `app_users` accounts as the web console, so a manager who
/// can open the back office can open the till with the same details — one
/// account store means deactivating a leaver closes both doors at once.
///
/// ```
/// POST {base}/login
/// { "authname": "…", "password": "…", "terminal_id": "T5EDD" }
/// ```
///
/// answered with `{ code, status, details: { token, store_id, locations, … } }`.
class PosAuthApi {
PosAuthApi({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client();
final String baseUrl;
final http.Client _client;
/// Generous, because this runs on a shop's connection while somebody watches.
/// Short enough that a dead endpoint is reported rather than hung on.
static const _timeout = Duration(seconds: 20);
/// Exchanges credentials for a session.
///
/// [locationId] is only meaningful for an account entitled to several
/// outlets: it says which one this terminal is standing in. It is a request,
/// not an assertion — the back office checks it against what the account may
/// actually reach, and that check is the whole point of the endpoint.
Future<PosSession> login({
required String authname,
required String password,
String? terminalId,
String? deviceId,
int? locationId,
int? configId,
}) async {
if (baseUrl.isEmpty) {
throw const PosAuthException(
'This terminal has no back office configured. Set the endpoint in '
'Settings → Connectivity & sync.',
);
}
final body = <String, Object?>{
'authname': authname.trim(),
'password': password,
if (terminalId != null && terminalId.isNotEmpty) 'terminal_id': terminalId,
if (deviceId != null && deviceId.isNotEmpty) 'device_id': deviceId,
if (locationId != null && locationId > 0) 'location_id': locationId,
// Sent only when known. The backend infers it when absent, and a shop
// has no way to find out what its configid is.
if (configId != null && configId > 0) 'configid': configId,
};
final http.Response response;
try {
response = await _client
.post(
Uri.parse('$baseUrl/login'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode(body),
)
.timeout(_timeout);
} on TimeoutException {
throw const PosAuthException(
'The back office did not answer in time. Check the connection and try '
'again.',
);
} on Object {
throw const PosAuthException(
'Could not reach the back office. Check the connection and try again.',
);
}
Map<String, Object?> decoded;
try {
decoded = jsonDecode(response.body) as Map<String, Object?>;
} on Object {
throw PosAuthException(
'The back office answered with something this terminal could not read '
'(HTTP ${response.statusCode}).',
);
}
if (response.statusCode != 200) {
throw PosAuthException(
(decoded['message'] as String?) ??
'Sign-in was refused (HTTP ${response.statusCode}).',
// 401 is a wrong email or password; 403 is a real account that may not
// open this till. Only the first is worth re-typing.
isCredentialFailure: response.statusCode == 401,
);
}
final details = decoded['details'];
if (details is! Map<String, Object?>) {
throw const PosAuthException(
'The back office accepted the sign-in but returned no session.',
);
}
final session = PosSession.fromJson(details);
// A session with no token cannot authenticate anything, and one with no
// outlet cannot bill. Refused here rather than being saved and failing
// later against every request, which would be much harder to diagnose.
if (session.token.isEmpty) {
throw const PosAuthException(
'The back office returned a session with no token.',
);
}
if (session.locationId <= 0) {
throw const PosAuthException(
'This account is not attached to an outlet, so it cannot open a till.',
);
}
return session;
}
void dispose() => _client.close();
}

View File

@@ -0,0 +1,281 @@
/// What the back office answers a sign-in with.
///
/// Replaces the arrangement where a till held a store id typed into Settings
/// and a password compiled into the app. That made the store id a *claim*: any
/// terminal could name any outlet and be believed, so one leaked build reached
/// every tenant on the platform.
///
/// Now the outlet arrives *from* the back office as a consequence of who signed
/// in, sealed inside a signed token the terminal cannot edit. The till stops
/// deciding which shop it belongs to and starts being told.
class PosSession {
const PosSession({
required this.token,
required this.expiresAt,
required this.userId,
required this.fullName,
required this.roleId,
required this.tenantId,
required this.tenantName,
required this.storeId,
required this.locationId,
required this.locationName,
this.email = '',
this.role = '',
this.canManageStaff = false,
this.gstin = '',
this.address = '',
this.phone = '',
this.outlets = const [],
this.staff = const [],
});
/// The bearer token, sent on every request from here on.
///
/// Opaque on purpose. The terminal must not parse it, reason about it, or
/// trust anything it appears to say — its only correct use is to hand it back
/// and let the server decide what it means.
final String token;
final DateTime expiresAt;
final int userId;
final String fullName;
final String email;
final int roleId;
/// The back office's name for [roleId] — "Supervisor", "Cashier", "Admin".
///
/// For display. Never branch on it: `app_roles` holds six rows for four
/// distinct roles, and a great many accounts carry a `roleid` that is not in
/// the table at all and come back blank. [canManageStaff] is the flag to
/// read.
final String role;
/// Whether this account runs the terminal or only bills on it.
///
/// The one permission the till acts on, and it is answered by the back office
/// rather than worked out here. A supervisor gets the full shell; a cashier
/// gets the billing screen. Deciding it locally would mean shipping a copy of
/// the role table in the app and keeping the two in step for ever.
///
/// Defaults to false, which is the least-privileged answer. That matters on
/// the restore path: a session saved by a build that predates this field
/// comes back as a cashier rather than silently as an admin.
final bool canManageStaff;
final int tenantId;
final String tenantName;
/// The outlet this terminal bills for, as a string because that is the shape
/// the sync configuration and every uplink already use.
final String storeId;
final int locationId;
final String locationName;
/// Printed on the invoice, so a legal requirement rather than decoration.
/// Arriving with the session means a shop that corrects its GSTIN in the back
/// office sees it on the next receipt instead of at the next rebuild.
final String gstin;
final String address;
final String phone;
/// Every outlet this account may open a till at.
///
/// A single-shop user gets a list of one, so the sign-in flow has no special
/// case: it offers a choice when there is one and skips it when there is not.
final List<PosOutlet> outlets;
bool get hasChoiceOfOutlet => outlets.length > 1;
/// The people the back office says may ring a bill here.
///
/// Very often empty. Only 116 of 596 accounts on the platform have a PIN set,
/// and most outlets — including the one this terminal ships pointed at — have
/// none at all. A till must treat that as an unfinished setup rather than as
/// a failure, which is why the seeded accounts still exist as a last resort.
final List<PosStaffMember> staff;
/// Whether the session is still worth sending.
///
/// Checked against the terminal's own clock, which is the only one available
/// offline. A till whose clock is wrong will re-authenticate unnecessarily —
/// annoying, and much better than billing a whole day against a session the
/// server has already stopped accepting.
bool isValidAt(DateTime now) => now.isBefore(expiresAt);
PosSession copyWith({
String? storeId,
int? locationId,
String? locationName,
String? address,
}) =>
PosSession(
token: token,
expiresAt: expiresAt,
userId: userId,
fullName: fullName,
email: email,
roleId: roleId,
role: role,
canManageStaff: canManageStaff,
tenantId: tenantId,
tenantName: tenantName,
storeId: storeId ?? this.storeId,
locationId: locationId ?? this.locationId,
locationName: locationName ?? this.locationName,
gstin: gstin,
address: address ?? this.address,
phone: phone,
outlets: outlets,
staff: staff,
);
factory PosSession.fromJson(Map<String, Object?> json) {
final outlets = (json['locations'] as List<Object?>? ?? const [])
.whereType<Map<String, Object?>>()
.map(PosOutlet.fromJson)
.toList();
return PosSession(
token: (json['token'] as String?) ?? '',
// A session with no readable expiry is treated as already finished rather
// than as never finishing. Guessing "valid" here would keep a till
// sending a token the server stopped honouring hours ago.
expiresAt: DateTime.tryParse((json['expires_at'] as String?) ?? '')
?.toLocal() ??
DateTime.fromMillisecondsSinceEpoch(0),
userId: _int(json['user_id']),
fullName: (json['full_name'] as String?)?.trim() ?? '',
email: (json['email'] as String?) ?? '',
roleId: _int(json['role_id']),
role: ((json['role'] as String?) ?? '').trim(),
canManageStaff: json['can_manage_staff'] == true,
tenantId: _int(json['tenant_id']),
tenantName: (json['tenant_name'] as String?) ?? '',
storeId: (json['store_id'] as String?) ?? '${_int(json['location_id'])}',
locationId: _int(json['location_id']),
locationName: (json['location_name'] as String?) ?? '',
gstin: (json['gstin'] as String?) ?? '',
address: (json['address'] as String?) ?? '',
phone: (json['phone'] as String?) ?? '',
outlets: outlets,
staff: (json['staff'] as List<Object?>? ?? const [])
.whereType<Map<String, Object?>>()
.map(PosStaffMember.fromJson)
.where((m) => m.pin.isNotEmpty)
.toList(),
);
}
Map<String, Object?> toJson() => {
'token': token,
'expires_at': expiresAt.toIso8601String(),
'user_id': userId,
'full_name': fullName,
'email': email,
'role_id': roleId,
'role': role,
'can_manage_staff': canManageStaff,
'tenant_id': tenantId,
'tenant_name': tenantName,
'store_id': storeId,
'location_id': locationId,
'location_name': locationName,
'gstin': gstin,
'address': address,
'phone': phone,
'locations': outlets.map((o) => o.toJson()).toList(),
'staff': staff.map((m) => m.toJson()).toList(),
};
}
/// One outlet a signed-in account may bill for.
class PosOutlet {
const PosOutlet({
required this.locationId,
required this.locationName,
this.address = '',
this.city = '',
});
final int locationId;
final String locationName;
final String address;
final String city;
String get storeId => '$locationId';
factory PosOutlet.fromJson(Map<String, Object?> json) => PosOutlet(
locationId: _int(json['location_id']),
locationName: (json['location_name'] as String?) ?? '',
address: (json['address'] as String?) ?? '',
city: (json['city'] as String?) ?? '',
);
Map<String, Object?> toJson() => {
'location_id': locationId,
'location_name': locationName,
'address': address,
'city': city,
};
}
/// Reads an id that may arrive as a number or as a string.
///
/// The backend sends `location_id` as an int and `store_id` as a string for the
/// same value, and a till that accepted only one shape would silently read zero
/// for the other — which looks like "no outlet" rather than like a bug.
int _int(Object? value) => switch (value) {
final int v => v,
final num v => v.toInt(),
final String v => int.tryParse(v.trim()) ?? 0,
_ => 0,
};
/// One person the back office says may ring a bill at this outlet.
///
/// The PIN arrives in the clear over TLS and is hashed before it touches disk —
/// see [PosSession] and the backend's `PosStaffMember` for why hashing it
/// server-side would have bought the appearance of strength and not the
/// substance. A four-digit PIN is brute-forceable in microseconds regardless;
/// what it is, is *shift attribution* — which of the people already inside a
/// shop gets credited with a sale. The security boundary is the session token.
class PosStaffMember {
const PosStaffMember({
required this.userId,
required this.fullName,
required this.pin,
this.role = '',
});
final int userId;
final String fullName;
final String pin;
/// The back office's own role name — "Admin", "Manager", "Operations". Blank
/// for the many accounts whose `roleid` is not in `app_roles` at all.
final String role;
/// A stable local id for a row that came from the back office.
///
/// Prefixed so an imported account can be told apart from one seeded on this
/// device. That distinction is what lets a sync retire the seeded logins
/// without touching anything a shop created itself.
String get localId => 'boffice-$userId';
factory PosStaffMember.fromJson(Map<String, Object?> json) =>
PosStaffMember(
userId: _int(json['user_id']),
fullName: ((json['full_name'] as String?) ?? '').trim(),
pin: ((json['pin'] as String?) ?? '').trim(),
role: ((json['role'] as String?) ?? '').trim(),
);
Map<String, Object?> toJson() => {
'user_id': userId,
'full_name': fullName,
'pin': pin,
'role': role,
};
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/providers.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'; import '../../../domain/entities/store_account.dart';
/// Sign-in state for the terminal. /// Sign-in state for the terminal.
@@ -28,8 +31,8 @@ class Authenticated extends AuthState {
final StoreAccount store; final StoreAccount store;
final StaffUser user; final StaffUser user;
/// Which credential opened this session. The authority on what the terminal /// What this session may open. The authority on what the terminal shows —
/// is allowed to show — not [user], which can be swapped at the till. /// not [user], which can be swapped at the till without re-authenticating.
final TerminalLogin login; final TerminalLogin login;
StaffRole get role => login.role; StaffRole get role => login.role;
@@ -44,80 +47,92 @@ class AuthFailure extends AuthState {
final String message; final String message;
} }
/// The two ways into this terminal. /// What a signed-in account may do with this terminal.
/// ///
/// Store-level credentials, not a person's: they are replaced wholesale when /// Two shapes, because the terminal only ever behaves in two ways, and the
/// the terminal is registered against a real back office. Staff PINs — the /// split is what the roles are *for* rather than decoration:
/// credential that actually opens a till drawer — are not here. They live
/// hashed in the database.
///
/// The split is what the two roles are *for*, not decoration:
/// ///
/// * [admin] runs the whole shell and is the only login that can pull the /// * [admin] runs the whole shell and is the only login that can pull the
/// catalogue. Signing out leaves the products on the terminal. /// catalogue. Signing out leaves the products on the terminal.
/// * [cashier] gets the billing screen and nothing else, and signing out /// * [cashier] gets the billing screen and nothing else, and signing out
/// takes the catalogue with it. /// 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 { enum TerminalLogin {
admin( admin(
label: 'Admin', label: 'Supervisor',
email: 'admin@nearle.in',
password: 'nearle123',
role: StaffRole.admin, role: StaffRole.admin,
blurb: 'Full shell — import products, promos, settings.', blurb: 'Full shell — import products, promos, staff, settings.',
), ),
cashier( cashier(
label: 'Cashier', label: 'Cashier',
email: 'cashier@nearle.in',
password: 'cashier123',
role: StaffRole.cashier, role: StaffRole.cashier,
blurb: 'Billing only, on the products the admin imported.', blurb: 'Billing only, on the products the supervisor imported.',
); );
const TerminalLogin({ const TerminalLogin({
required this.label, required this.label,
required this.email,
required this.password,
required this.role, required this.role,
required this.blurb, required this.blurb,
}); });
final String label; final String label;
final String email;
final String password;
final StaffRole role; final StaffRole role;
final String blurb; final String blurb;
/// The catalogue is pulled once by an admin and billed against by whoever is /// The catalogue is pulled once by a supervisor and billed against by
/// on the counter, so only the cashier's sign-out drops it. An admin closing /// whoever is on the counter, so only the cashier's sign-out drops it. A
/// the shell is a handover, not the end of the day. /// supervisor closing the shell is a handover, not the end of the day.
bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier; bool get clearsCatalogueOnSignOut => this == TerminalLogin.cashier;
static TerminalLogin? byEmail(String email) { /// Which shell the back office says this account gets.
final normalised = email.trim().toLowerCase(); ///
for (final login in TerminalLogin.values) { /// Reads `can_manage_staff` rather than the role name or id. The name is
if (login.email == normalised) return login; /// 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
return null; /// 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;
} }
/// Kept for the store record, which is keyed on the outlet's own address. /// Signs the terminal in against the back office and holds the session.
class DemoCredentials { ///
const DemoCredentials._(); /// 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
static const String email = 'admin@nearle.in'; /// wrong with that, and the second was the serious one:
static const String password = 'nearle123'; ///
/// 1. every install of a build shared one password, and changing it meant a
static const String cashierEmail = 'cashier@nearle.in'; /// rebuild; and
static const String cashierPassword = 'cashier123'; /// 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
/// Validates store credentials and holds the signed-in session. /// 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> { class AuthController extends StateNotifier<AuthState> {
AuthController(this._ref) : super(const Unauthenticated()); AuthController(this._ref) : super(const Unauthenticated());
final Ref _ref; 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. /// Whether signing out right now would wipe the products off this terminal.
/// ///
/// Read *before* [signOut] by anything that needs to warn the operator, since /// Read *before* [signOut] by anything that needs to warn the operator, since
@@ -127,27 +142,121 @@ class AuthController extends StateNotifier<AuthState> {
return current is Authenticated && current.login.clearsCatalogueOnSignOut; 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({ Future<bool> signIn({
required String email, required String email,
required String password, required String password,
int? locationId,
}) async { }) async {
state = const Authenticating(); state = const Authenticating();
// Stand-in for the network round trip. final terminal = _ref.read(terminalIdentityProvider);
await Future<void>.delayed(const Duration(milliseconds: 600));
final login = TerminalLogin.byEmail(email); final PosSession session;
try {
if (login == null) { session = await _ref.read(posAuthApiProvider).login(
state = const AuthFailure('No account is registered against that email.'); 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; return false;
} }
if (password != login.password) { await _ref.read(sessionStoreProvider).write(session);
state = const AuthFailure('Incorrect password. Please try again.'); await _adopt(session);
return false;
}
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 store = await _ref.read(storeAccountProvider.future);
final staff = store.staff; final staff = store.staff;
@@ -155,22 +264,74 @@ class AuthController extends StateNotifier<AuthState> {
state = const AuthFailure( state = const AuthFailure(
'This terminal has no staff accounts. Reinstall to seed them.', 'This terminal has no staff accounts. Reinstall to seed them.',
); );
return false; return;
} }
// Whoever on this terminal matches the role that just signed in. Falls state = Authenticated(
// back rather than failing: the session's permissions come from [login], store: store,
// so a shop with no cashier row still gets a usable till — the bills are user: _opener(staff, session),
// just stamped with the account that is there. login: TerminalLogin.forSession(session),
final opener = staff.firstWhere(
(s) => s.role == login.role,
orElse: () => staff.first,
); );
state = Authenticated(store: store, user: opener, login: login);
return true;
} }
/// 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. /// Switches the active operator, checking their PIN.
/// ///
/// Every bill is stamped with whoever is active, so this is the boundary that /// Every bill is stamped with whoever is active, so this is the boundary that
@@ -178,6 +339,11 @@ class AuthController extends StateNotifier<AuthState> {
/// list. It changes who the bill names, never what the session may open: /// list. It changes who the bill names, never what the session may open:
/// [Authenticated.login] is untouched, so a cashier terminal stays a cashier /// [Authenticated.login] is untouched, so a cashier terminal stays a cashier
/// terminal. /// 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 { Future<bool> switchUser(String pin) async {
final current = state; final current = state;
if (current is! Authenticated) return false; if (current is! Authenticated) return false;
@@ -214,19 +380,29 @@ class AuthController extends StateNotifier<AuthState> {
/// Ends the session, and — for a cashier only — the catalogue with it. /// Ends the session, and — for a cashier only — the catalogue with it.
/// ///
/// Every way out of a cashier session clears the products: ending a shift, /// The token always goes, from the keystore and from the live configuration
/// and a temporary logout alike. There is no exception for stepping away for /// both. Leaving it in place would let a signed-out terminal keep uploading
/// ten minutes, because the terminal is left unattended either way and the /// as the shop that signed in this morning, so that half is a security
/// next session should bill against what the back office answers with rather /// matter and takes no exception.
/// than a catalogue carried over.
/// ///
/// An admin signing out is the opposite case. They have just pulled the /// The catalogue is a workflow matter and does take one. Every way out of a
/// products *so that* a cashier can pick the terminal up, so dropping the /// cashier session clears the products — ending a shift and a temporary
/// table here would make the import pointless. /// 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 { Future<void> signOut() async {
if (clearsCatalogueOnSignOut) { if (clearsCatalogueOnSignOut) {
await _ref.read(localStoreProvider).clearCatalogue(); 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(); state = const Unauthenticated();
} }

View File

@@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -10,7 +8,6 @@ import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/validators.dart'; import '../../../core/utils/validators.dart';
import '../../../core/widgets/brand_mark.dart';
import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/primary_button.dart';
import '../providers/auth_controller.dart'; import '../providers/auth_controller.dart';
@@ -32,27 +29,15 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> { class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
/// Which of the two default accounts the tabs are pointing at. Only a // Both fields start empty. There is nothing to prefill: a person signs in
/// convenience for filling the fields — [AuthController.signIn] decides the // with their own back-office account, and which shell they get is a property
/// role from the email that is actually submitted, so typing a different // of that account rather than a tab they picked before typing.
/// address over the top still signs in as that account. final _email = TextEditingController();
TerminalLogin _login = TerminalLogin.admin; final _password = TextEditingController();
late final _email = TextEditingController(text: _login.email);
late final _password = TextEditingController(text: _login.password);
bool _obscure = true; bool _obscure = true;
bool _rememberTerminal = true; bool _rememberTerminal = true;
void _selectLogin(TerminalLogin login) {
setState(() {
_login = login;
_email.text = login.email;
_password.text = login.password;
});
ref.read(authControllerProvider.notifier).clearError();
}
@override @override
void dispose() { void dispose() {
_email.dispose(); _email.dispose();
@@ -124,10 +109,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
password: _password, password: _password,
obscure: _obscure, obscure: _obscure,
rememberTerminal: _rememberTerminal, rememberTerminal: _rememberTerminal,
login: _login,
busy: busy, busy: busy,
failure: auth is AuthFailure ? auth.message : null, failure: auth is AuthFailure ? auth.message : null,
onSelectLogin: _selectLogin,
onToggleObscure: () => onToggleObscure: () =>
setState(() => _obscure = !_obscure), setState(() => _obscure = !_obscure),
onToggleRemember: (v) => onToggleRemember: (v) =>
@@ -203,10 +186,8 @@ class _Card extends StatelessWidget {
required this.password, required this.password,
required this.obscure, required this.obscure,
required this.rememberTerminal, required this.rememberTerminal,
required this.login,
required this.busy, required this.busy,
required this.failure, required this.failure,
required this.onSelectLogin,
required this.onToggleObscure, required this.onToggleObscure,
required this.onToggleRemember, required this.onToggleRemember,
required this.onSubmit, required this.onSubmit,
@@ -217,10 +198,8 @@ class _Card extends StatelessWidget {
final TextEditingController password; final TextEditingController password;
final bool obscure; final bool obscure;
final bool rememberTerminal; final bool rememberTerminal;
final TerminalLogin login;
final bool busy; final bool busy;
final String? failure; final String? failure;
final ValueChanged<TerminalLogin> onSelectLogin;
final VoidCallback onToggleObscure; final VoidCallback onToggleObscure;
final ValueChanged<bool?> onToggleRemember; final ValueChanged<bool?> onToggleRemember;
final VoidCallback onSubmit; final VoidCallback onSubmit;
@@ -268,22 +247,6 @@ class _Card extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.xl), const SizedBox(height: AppSpacing.xl),
_RoleSwitch(
selected: login,
enabled: !busy,
onSelect: onSelectLogin,
),
const SizedBox(height: AppSpacing.sm),
Text(
login.blurb,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textTertiary,
height: 1.45,
),
),
const SizedBox(height: AppSpacing.xl),
const _Label('Store email'), const _Label('Store email'),
TextFormField( TextFormField(
controller: email, controller: email,
@@ -403,12 +366,6 @@ class _Card extends StatelessWidget {
busy: busy, busy: busy,
onPressed: onSubmit, onPressed: onSubmit,
), ),
const SizedBox(height: AppSpacing.lg),
_DemoHint(
login: login,
onFill: busy ? null : () => onSelectLogin(login),
),
], ],
), ),
), ),
@@ -436,168 +393,3 @@ class _Label extends StatelessWidget {
); );
} }
} }
/// Which of the two default accounts is being signed into.
///
/// The roles are not cosmetic — they decide whether the terminal opens the
/// full shell or the billing screen alone, and whether signing out leaves the
/// products behind — so the choice is made before the credentials rather than
/// inferred from them afterwards.
class _RoleSwitch extends StatelessWidget {
const _RoleSwitch({
required this.selected,
required this.enabled,
required this.onSelect,
});
final TerminalLogin selected;
final bool enabled;
final ValueChanged<TerminalLogin> onSelect;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
for (final login in TerminalLogin.values)
Expanded(
child: _RoleTab(
login: login,
selected: login == selected,
enabled: enabled,
onTap: () => onSelect(login),
),
),
],
),
);
}
}
class _RoleTab extends StatelessWidget {
const _RoleTab({
required this.login,
required this.selected,
required this.enabled,
required this.onTap,
});
final TerminalLogin login;
final bool selected;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final icon = login == TerminalLogin.admin
? Icons.admin_panel_settings_outlined
: Icons.point_of_sale_rounded;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: AppRadius.brXs,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.surface : Colors.transparent,
borderRadius: AppRadius.brXs,
border: Border.all(
color: selected ? AppColors.primaryBorder : Colors.transparent,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 17,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(
child: Text(
login.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color:
selected ? AppColors.primary : AppColors.textSecondary,
),
),
),
],
),
),
),
);
}
}
class _DemoHint extends StatelessWidget {
const _DemoHint({required this.login, this.onFill});
final TerminalLogin login;
final VoidCallback? onFill;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 17, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Default ${login.label.toLowerCase()} account',
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(height: 2),
SelectableText(
'${login.email} \u00b7 ${login.password}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
TextButton(
onPressed: onFill,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
),
child: const Text('Fill', style: TextStyle(fontSize: 12.5)),
),
],
),
);
}
}

View File

@@ -93,8 +93,10 @@ class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
/// step. /// step.
Future<void> _save() async { Future<void> _save() async {
if (!_complete) { if (!_complete) {
setState(() => _error = 'Enter all ' setState(
'${AppConstants.mobileNumberLength} digits of the mobile number.'); () => _error = 'Enter all '
'${AppConstants.mobileNumberLength} digits of the mobile number.',
);
return; return;
} }

View File

@@ -9,7 +9,6 @@ import '../../auth/providers/auth_controller.dart';
import '../../sync/providers/sync_controller.dart'; import '../../sync/providers/sync_controller.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../widgets/category_chips.dart'; import '../widgets/category_chips.dart';
import '../widgets/customer_bar.dart';
import '../widgets/product_grid.dart'; import '../widgets/product_grid.dart';
import '../widgets/scan_toast.dart'; import '../widgets/scan_toast.dart';
import '../widgets/search_field.dart'; import '../widgets/search_field.dart';

View File

@@ -11,7 +11,6 @@ import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart'; import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/shift_report.dart'; import '../../../domain/entities/shift_report.dart';
import '../../../domain/entities/transaction.dart'; import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../../payment/screens/payment_screen.dart' show methodIcon; import '../../payment/screens/payment_screen.dart' show methodIcon;
import '../../pos/providers/cart_controller.dart'; import '../../pos/providers/cart_controller.dart';

View File

@@ -222,6 +222,18 @@ final syncBootstrapProvider = FutureProvider<void>((ref) async {
await store.syncConfig.load(ref.read(syncConfigProvider)); await store.syncConfig.load(ref.read(syncConfigProvider));
} }
// Bring back the session this terminal was signed in under.
//
// Runs before the engine starts, and that ordering is load-bearing: the
// session carries both the bearer token and the outlet, so a drain that began
// first would upload the day's bills unauthenticated — and, once the backend
// is enforcing, have them refused.
//
// A till signs in when a shop opens and trades all day. Without this a reboot
// mid-shift would put a login screen in front of a queue of customers, which
// is a worse outage than the one it protects against.
await ref.read(authControllerProvider.notifier).restore();
await ref.read(connectivityServiceProvider).start(); await ref.read(connectivityServiceProvider).start();
final engine = ref.read(syncEngineProvider); final engine = ref.read(syncEngineProvider);

View File

@@ -95,7 +95,7 @@ class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
if (outcome.isSuccess) { if (outcome.isSuccess) {
await Future<void>.delayed(const Duration(milliseconds: 700)); await Future<void>.delayed(const Duration(milliseconds: 700));
if (mounted) _finish(); if (mounted) await _finish();
} }
} }

View File

@@ -181,7 +181,17 @@ void main() {
/// Holds a fixed session so a test can choose who is signed in. /// Holds a fixed session so a test can choose who is signed in.
class _StubAuth extends AuthController { class _StubAuth extends AuthController {
_StubAuth(StoreAccount store, StaffUser user) : super(_throwingRef) { _StubAuth(StoreAccount store, StaffUser user) : super(_throwingRef) {
state = Authenticated(store: store, user: user); // The shell a session opens is decided by the back office, not by the
// person at the counter — so a stub has to state it too. Taken from the
// user's role here purely so these tests keep reading as "signed in as the
// admin" / "signed in as the cashier".
state = Authenticated(
store: store,
user: user,
login: user.role == StaffRole.cashier
? TerminalLogin.cashier
: TerminalLogin.admin,
);
} }
@override @override

View File

@@ -51,7 +51,7 @@ void main() {
cashierName: 'Suriya', cashierName: 'Suriya',
); );
Future<void> bootApp(WidgetTester tester) async { Future<void> bootApp(WidgetTester tester, {bool supervisor = true}) async {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
@@ -70,7 +70,9 @@ void main() {
// rather than two constants compiled into the build. A widget test // rather than two constants compiled into the build. A widget test
// must not depend on a live endpoint, so the client is swapped for // must not depend on a live endpoint, so the client is swapped for
// one that answers with a fixed session. // one that answers with a fixed session.
posAuthApiProvider.overrideWithValue(_FakePosAuthApi()), posAuthApiProvider.overrideWithValue(
_FakePosAuthApi(canManageStaff: supervisor),
),
// Catalogue reads come from the in-memory cache and resolve on the // Catalogue reads come from the in-memory cache and resolve on the
// spot, but these four go to SQLite. Real disk I/O cannot be driven // spot, but these four go to SQLite. Real disk I/O cannot be driven
@@ -167,10 +169,49 @@ void main() {
await tester.tap(target.first); await tester.tap(target.first);
await settle(tester); await settle(tester);
// Settings builds a printer-settings controller that reads six values
// out of SQLite, and sqflite arms a ten-second lock-warning timer around
// each. Those reads cannot complete on a fake clock, so the timer would
// still be pending at teardown and the binding would fail the test for
// that rather than for anything it is about. Pumping past the ten
// seconds lets the timer fire and clear.
await tester.pump(const Duration(seconds: 11));
expect(tester.takeException(), isNull, reason: 'opening "$label" threw'); expect(tester.takeException(), isNull, reason: 'opening "$label" threw');
} }
}); });
testWidgets('a cashier session gets billing and nothing else',
(tester) async {
// The other half of the role split, and the half worth pinning: the shell
// a person gets is decided by the back office, not by which tab they
// picked on the way in. Same credentials, same screen size, same boot —
// the only difference is `can_manage_staff` on the session, and the
// back-office modules have to be unreachable because of it.
tester.view.physicalSize = const Size(1800, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await bootApp(tester, supervisor: false);
await signIn(tester);
// Signed in, and on the billing screen.
expect(find.byType(PosDashboardScreen), findsOneWidget);
// These labels exist only in the sidebar, so their absence is the whole
// claim: a cashier cannot reach the catalogue, the promos or the
// terminal's configuration.
for (final label in ['Product Import', 'Promo', 'Settings']) {
expect(
find.text(label),
findsNothing,
reason: 'a cashier must not be offered "$label"',
);
}
expect(tester.takeException(), isNull);
});
testWidgets('the back office connection dialog opens and validates', testWidgets('the back office connection dialog opens and validates',
(tester) async { (tester) async {
// The only way a shop can point a till at a broker. Until it existed a // The only way a shop can point a till at a broker. Until it existed a
@@ -185,6 +226,9 @@ void main() {
await tester.tap(find.text('Settings').first); await tester.tap(find.text('Settings').first);
await settle(tester); await settle(tester);
// Clears sqflite's lock-warning timer — see the module loop above.
await tester.pump(const Duration(seconds: 11));
await tester.tap(find.text('Configure').first); await tester.tap(find.text('Configure').first);
await settle(tester); await settle(tester);
@@ -217,7 +261,12 @@ const _testPassword = 'correct-horse';
/// concrete — and answering a wrong password correctly matters here: the login /// concrete — and answering a wrong password correctly matters here: the login
/// screen's failure path is part of what these tests cover. /// screen's failure path is part of what these tests cover.
class _FakePosAuthApi extends PosAuthApi { class _FakePosAuthApi extends PosAuthApi {
_FakePosAuthApi() : super(baseUrl: 'https://example.invalid/pos'); _FakePosAuthApi({this.canManageStaff = true})
: super(baseUrl: 'https://example.invalid/pos');
/// Which shell the back office says this account gets. A supervisor by
/// default, because most of these tests are about the full shell rendering.
final bool canManageStaff;
@override @override
Future<PosSession> login({ Future<PosSession> login({
@@ -241,7 +290,9 @@ class _FakePosAuthApi extends PosAuthApi {
userId: 1229, userId: 1229,
fullName: 'Test Manager', fullName: 'Test Manager',
email: _testEmail, email: _testEmail,
roleId: 0, roleId: canManageStaff ? 7 : 8,
role: canManageStaff ? 'Supervisor' : 'Cashier',
canManageStaff: canManageStaff,
tenantId: 1087, tenantId: 1087,
tenantName: 'Ragul Stores', tenantName: 'Ragul Stores',
storeId: '1135', storeId: '1135',

View File

@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}