added login

This commit is contained in:
2026-08-07 17:07:49 +05:30
parent ad44402232
commit 4a2474ee6c
8 changed files with 908 additions and 383 deletions

View 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);
}