added login
This commit is contained in:
287
lib/domain/entities/pos_session.dart
Normal file
287
lib/domain/entities/pos_session.dart
Normal file
@@ -0,0 +1,287 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'store_account.dart';
|
||||
|
||||
/// One outlet the signed-in account is allowed to work.
|
||||
///
|
||||
/// A supervisor at a single-shop tenant gets one entry; a multi-outlet account
|
||||
/// gets the list, which is what an outlet picker would be built from.
|
||||
class SessionLocation extends Equatable {
|
||||
const SessionLocation({
|
||||
required this.locationId,
|
||||
required this.locationName,
|
||||
required this.address,
|
||||
required this.city,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final int locationId;
|
||||
final String locationName;
|
||||
final String address;
|
||||
final String city;
|
||||
final String status;
|
||||
|
||||
bool get isActive => status.toLowerCase() == 'active';
|
||||
|
||||
factory SessionLocation.fromJson(Map<String, Object?> json) =>
|
||||
SessionLocation(
|
||||
locationId: _asInt(json['location_id']),
|
||||
locationName: _asString(json['location_name']),
|
||||
address: _asString(json['address']),
|
||||
city: _asString(json['city']),
|
||||
status: _asString(json['status']),
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'location_id': locationId,
|
||||
'location_name': locationName,
|
||||
'address': address,
|
||||
'city': city,
|
||||
'status': status,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [locationId, locationName, address, city, status];
|
||||
}
|
||||
|
||||
/// A person the back office says may work this terminal.
|
||||
///
|
||||
/// The `pin` the server returns is in the clear. It is kept because switching
|
||||
/// operators at the till is a PIN entry and nothing else, but it is the reason
|
||||
/// [PosSession] is written to the platform keystore rather than to SQLite —
|
||||
/// and it is worth pushing the back office to return a hash instead.
|
||||
class SessionStaff extends Equatable {
|
||||
const SessionStaff({
|
||||
required this.userId,
|
||||
required this.fullName,
|
||||
required this.role,
|
||||
required this.pin,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String fullName;
|
||||
final String role;
|
||||
final String pin;
|
||||
final String status;
|
||||
|
||||
bool get isActive => status.toLowerCase() == 'active';
|
||||
|
||||
factory SessionStaff.fromJson(Map<String, Object?> json) => SessionStaff(
|
||||
userId: _asInt(json['user_id']),
|
||||
fullName: _asString(json['full_name']),
|
||||
role: _asString(json['role']),
|
||||
pin: _asString(json['pin']),
|
||||
status: _asString(json['status']),
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'user_id': userId,
|
||||
'full_name': fullName,
|
||||
'role': role,
|
||||
'pin': pin,
|
||||
'status': status,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [userId, fullName, role, pin, status];
|
||||
}
|
||||
|
||||
/// Everything `POST /pos/login` answered with, plus the account it was issued
|
||||
/// to.
|
||||
///
|
||||
/// This is the whole session: the bearer token every later call needs, who is
|
||||
/// signed in, and which outlet the terminal is now trading as. It is persisted
|
||||
/// verbatim so a restart does not force a fresh sign-in, and dropped entirely
|
||||
/// on sign-out.
|
||||
class PosSession extends Equatable {
|
||||
const PosSession({
|
||||
required this.token,
|
||||
required this.authname,
|
||||
required this.userId,
|
||||
required this.fullName,
|
||||
required this.roleId,
|
||||
required this.role,
|
||||
required this.tenantId,
|
||||
required this.tenantName,
|
||||
required this.storeId,
|
||||
required this.locationId,
|
||||
required this.locationName,
|
||||
required this.address,
|
||||
required this.gstin,
|
||||
required this.phone,
|
||||
this.expiresAt,
|
||||
this.canManageStaff = false,
|
||||
this.locations = const [],
|
||||
this.staff = const [],
|
||||
});
|
||||
|
||||
/// Bearer token for every subsequent call. Never logged, never printed.
|
||||
final String token;
|
||||
|
||||
/// The credential this session was opened with. Kept only so the login
|
||||
/// screen can pre-fill it on the next shift.
|
||||
final String authname;
|
||||
|
||||
final int userId;
|
||||
final String fullName;
|
||||
|
||||
/// Numeric role from the back office (7 = Supervisor on this tenant).
|
||||
///
|
||||
/// Recorded, but never the thing that decides what the terminal opens — see
|
||||
/// [isCashier]. Ids are tenant configuration and can be renumbered; the role
|
||||
/// name is the stable contract.
|
||||
final int roleId;
|
||||
|
||||
/// Role name as the server spells it — `Supervisor`, `Cashier`, `Admin`.
|
||||
final String role;
|
||||
|
||||
final bool canManageStaff;
|
||||
|
||||
final int tenantId;
|
||||
final String tenantName;
|
||||
|
||||
/// The outlet, as a string, matching what the sync topics are namespaced on.
|
||||
final String storeId;
|
||||
final int locationId;
|
||||
final String locationName;
|
||||
|
||||
/// Printed on every invoice, so these come from the back office rather than
|
||||
/// from anything typed into this terminal.
|
||||
final String address;
|
||||
final String gstin;
|
||||
final String phone;
|
||||
|
||||
final DateTime? expiresAt;
|
||||
final List<SessionLocation> locations;
|
||||
final List<SessionStaff> staff;
|
||||
|
||||
/// Roles that get the full shell: catalogue import, promos, settings, staff.
|
||||
static const Set<String> adminRoles = {
|
||||
'admin',
|
||||
'administrator',
|
||||
'owner',
|
||||
'supervisor',
|
||||
'manager',
|
||||
'store manager',
|
||||
};
|
||||
|
||||
/// Whether this session is locked down to the billing screen.
|
||||
///
|
||||
/// Anything not in [adminRoles] lands here, including a role this build has
|
||||
/// never seen. A new back-office role must not silently inherit catalogue
|
||||
/// and settings access because nobody remembered to list it — the failure
|
||||
/// should be "the supervisor sees a till", which someone reports in a
|
||||
/// minute, not "the cashier can edit prices", which nobody notices.
|
||||
bool get isCashier => !adminRoles.contains(role.trim().toLowerCase());
|
||||
|
||||
/// How this account maps onto the terminal's own permission model.
|
||||
///
|
||||
/// Two values, not four: the shell has exactly two shapes, and every
|
||||
/// non-cashier role the back office issues is expected to be able to import
|
||||
/// products and edit the store's details — which is what `StaffRole.admin`
|
||||
/// unlocks locally.
|
||||
StaffRole get staffRole => isCashier ? StaffRole.cashier : StaffRole.admin;
|
||||
|
||||
/// The operator, in the shape the rest of the app already speaks.
|
||||
StaffUser get user => StaffUser(
|
||||
id: '$userId',
|
||||
name: fullName,
|
||||
role: staffRole,
|
||||
// The back office owns this credential now, so the terminal never
|
||||
// forces a PIN change on an account it did not seed.
|
||||
mustChangePin: false,
|
||||
);
|
||||
|
||||
bool get isExpired =>
|
||||
expiresAt != null && !DateTime.now().toUtc().isBefore(expiresAt!.toUtc());
|
||||
|
||||
/// Reads the `details` object of a successful login response.
|
||||
factory PosSession.fromDetails(
|
||||
Map<String, Object?> details, {
|
||||
required String authname,
|
||||
}) =>
|
||||
PosSession(
|
||||
token: _asString(details['token']),
|
||||
authname: authname,
|
||||
userId: _asInt(details['user_id']),
|
||||
fullName: _asString(details['full_name']),
|
||||
roleId: _asInt(details['role_id']),
|
||||
role: _asString(details['role']),
|
||||
canManageStaff: _asBool(details['can_manage_staff']),
|
||||
tenantId: _asInt(details['tenant_id']),
|
||||
tenantName: _asString(details['tenant_name']),
|
||||
storeId: _asString(details['store_id']),
|
||||
locationId: _asInt(details['location_id']),
|
||||
locationName: _asString(details['location_name']),
|
||||
address: _asString(details['address']),
|
||||
gstin: _asString(details['gstin']),
|
||||
phone: _asString(details['phone']),
|
||||
expiresAt: DateTime.tryParse(_asString(details['expires_at'])),
|
||||
locations: _asList(details['locations'], SessionLocation.fromJson),
|
||||
staff: _asList(details['staff'], SessionStaff.fromJson),
|
||||
);
|
||||
|
||||
/// Round-trips through [toJson], for reading back out of the keystore.
|
||||
factory PosSession.fromJson(Map<String, Object?> json) =>
|
||||
PosSession.fromDetails(json, authname: _asString(json['authname']));
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'token': token,
|
||||
'authname': authname,
|
||||
'user_id': userId,
|
||||
'full_name': fullName,
|
||||
'role_id': roleId,
|
||||
'role': role,
|
||||
'can_manage_staff': canManageStaff,
|
||||
'tenant_id': tenantId,
|
||||
'tenant_name': tenantName,
|
||||
'store_id': storeId,
|
||||
'location_id': locationId,
|
||||
'location_name': locationName,
|
||||
'address': address,
|
||||
'gstin': gstin,
|
||||
'phone': phone,
|
||||
'expires_at': expiresAt?.toUtc().toIso8601String(),
|
||||
'locations': locations.map((l) => l.toJson()).toList(),
|
||||
'staff': staff.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [token, userId, roleId, role, locationId];
|
||||
|
||||
/// Never let a token reach a log line or a crash report.
|
||||
@override
|
||||
String toString() =>
|
||||
'PosSession($fullName, $role, $locationName, expires $expiresAt)';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Decoding
|
||||
//
|
||||
// Tolerant on purpose. `store_id` arrives as a string and `location_id` as a
|
||||
// number for the same outlet, and a field the back office adds later must not
|
||||
// crash a till mid-shift.
|
||||
|
||||
String _asString(Object? value) => value == null ? '' : '$value';
|
||||
|
||||
int _asInt(Object? value) => switch (value) {
|
||||
final int v => v,
|
||||
final num v => v.toInt(),
|
||||
final String v => int.tryParse(v) ?? 0,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
bool _asBool(Object? value) => switch (value) {
|
||||
final bool v => v,
|
||||
final num v => v != 0,
|
||||
final String v => v.toLowerCase() == 'true' || v == '1',
|
||||
_ => false,
|
||||
};
|
||||
|
||||
List<T> _asList<T>(Object? raw, T Function(Map<String, Object?>) decode) {
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map<String, Object?>>()
|
||||
.map(decode)
|
||||
.toList(growable: false);
|
||||
}
|
||||
Reference in New Issue
Block a user