optimized auth service
This commit is contained in:
parent
28fbc2ad29
commit
f937bd849f
@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||
import 'package:on_field_work/helpers/services/permission_service.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/model/user_permission.dart';
|
||||
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||
import 'package:on_field_work/model/projects_model.dart';
|
||||
@ -51,7 +51,7 @@ class PermissionController extends GetxController {
|
||||
Future<void> loadData(String token) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final userData = await PermissionService.fetchAllUserData(token);
|
||||
final userData = await AuthService.fetchAllUserData(token);
|
||||
_updateState(userData);
|
||||
await _storeData();
|
||||
logSafe("Data loaded and state updated successfully.");
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/controller/permission_controller.dart';
|
||||
|
||||
class TenantSelectionController extends GetxController {
|
||||
final TenantService _tenantService = TenantService();
|
||||
|
||||
// Tenant list
|
||||
final tenants = <Tenant>[].obs;
|
||||
@ -32,7 +31,7 @@ class TenantSelectionController extends GetxController {
|
||||
isLoading.value = true;
|
||||
isAutoSelecting.value = true; // show splash during auto-selection
|
||||
try {
|
||||
final data = await _tenantService.getTenants();
|
||||
final data = await AuthService.getTenants();
|
||||
if (data == null || data.isEmpty) {
|
||||
tenants.clear();
|
||||
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
|
||||
@ -87,7 +86,7 @@ class TenantSelectionController extends GetxController {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
final success = await _tenantService.selectTenant(tenantId);
|
||||
final success = await AuthService.selectTenant(tenantId);
|
||||
if (!success) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
@ -99,7 +98,7 @@ class TenantSelectionController extends GetxController {
|
||||
|
||||
// Update tenant & persist
|
||||
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
||||
TenantService.setSelectedTenant(selectedTenant);
|
||||
AuthService.setSelectedTenant(selectedTenant);
|
||||
selectedTenantId.value = tenantId;
|
||||
await LocalStorage.setRecentTenantId(tenantId);
|
||||
|
||||
@ -131,6 +130,6 @@ class TenantSelectionController extends GetxController {
|
||||
/// Clear tenant selection
|
||||
void _clearSelection() {
|
||||
selectedTenantId.value = null;
|
||||
TenantService.currentTenant = null;
|
||||
AuthService.currentTenant = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
||||
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/controller/permission_controller.dart';
|
||||
|
||||
class TenantSwitchController extends GetxController {
|
||||
final TenantService _tenantService = TenantService();
|
||||
|
||||
final tenants = <Tenant>[].obs;
|
||||
final isLoading = false.obs;
|
||||
@ -23,7 +22,7 @@ class TenantSwitchController extends GetxController {
|
||||
Future<void> loadTenants() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final data = await _tenantService.getTenants();
|
||||
final data = await AuthService.getTenants();
|
||||
if (data == null || data.isEmpty) {
|
||||
tenants.clear();
|
||||
logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning);
|
||||
@ -33,7 +32,7 @@ class TenantSwitchController extends GetxController {
|
||||
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
|
||||
|
||||
// Keep current tenant as selected
|
||||
selectedTenantId.value = TenantService.currentTenant?.id;
|
||||
selectedTenantId.value = AuthService.currentTenant?.id;
|
||||
} catch (e, st) {
|
||||
logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st);
|
||||
showAppSnackbar(
|
||||
@ -48,11 +47,11 @@ class TenantSwitchController extends GetxController {
|
||||
|
||||
/// Switch to a different tenant and navigate fully
|
||||
Future<void> switchTenant(String tenantId) async {
|
||||
if (TenantService.currentTenant?.id == tenantId) return;
|
||||
if (AuthService.currentTenant?.id == tenantId) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final success = await _tenantService.selectTenant(tenantId);
|
||||
final success = await AuthService.selectTenant(tenantId);
|
||||
if (!success) {
|
||||
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
|
||||
showAppSnackbar(
|
||||
@ -64,7 +63,7 @@ class TenantSwitchController extends GetxController {
|
||||
}
|
||||
|
||||
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
||||
TenantService.setSelectedTenant(selectedTenant);
|
||||
AuthService.setSelectedTenant(selectedTenant);
|
||||
selectedTenantId.value = tenantId;
|
||||
|
||||
// Persist recent tenant
|
||||
|
||||
@ -1,25 +1,47 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:on_field_work/controller/project_controller.dart';
|
||||
import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||
import 'package:on_field_work/helpers/utils/encryption_helper.dart';
|
||||
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
||||
import 'package:on_field_work/model/user_permission.dart';
|
||||
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||
import 'package:on_field_work/model/projects_model.dart';
|
||||
|
||||
// Enum for standardizing HTTP methods within the service
|
||||
enum _HttpMethod { get, post }
|
||||
|
||||
class AuthService {
|
||||
static const String _baseUrl = ApiEndpoints.baseUrl;
|
||||
static const Map<String, String> _headers = {
|
||||
static const Map<String, String> _defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// AuthService properties
|
||||
static bool isLoggedIn = false;
|
||||
|
||||
/* -------------------------------------------------------------------------- /
|
||||
/ Logout API /
|
||||
/ -------------------------------------------------------------------------- */
|
||||
// TenantService properties
|
||||
static Tenant? currentTenant;
|
||||
|
||||
// PermissionService properties
|
||||
static final Map<String, Map<String, dynamic>> _userDataCache = {};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* AUTH METHODS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// Logs the user out by calling the logout API.
|
||||
static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
|
||||
try {
|
||||
final body = {"refreshToken": refreshToken, "fcmToken": fcmToken};
|
||||
final response = await _post("/auth/logout", body);
|
||||
final response = await _networkRequest(
|
||||
path: "/auth/logout",
|
||||
method: _HttpMethod.post,
|
||||
body: body,
|
||||
);
|
||||
|
||||
if (response != null && response['statusCode'] == 200) {
|
||||
logSafe("✅ Logout API successful");
|
||||
@ -35,10 +57,7 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- /
|
||||
/ Public Methods /
|
||||
/ -------------------------------------------------------------------------- */
|
||||
|
||||
/// Registers or updates the Firebase Cloud Messaging token.
|
||||
static Future<bool> registerDeviceToken(String fcmToken) async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
@ -48,23 +67,36 @@ class AuthService {
|
||||
}
|
||||
|
||||
final body = {"fcmToken": fcmToken};
|
||||
final data = await _post("/auth/set/device-token", body, authToken: token);
|
||||
final response = await _networkRequest(
|
||||
path: "/auth/set/device-token",
|
||||
method: _HttpMethod.post,
|
||||
body: body,
|
||||
authToken: token,
|
||||
);
|
||||
|
||||
if (data != null && data['success'] == true) {
|
||||
if (response != null && response['success'] == true) {
|
||||
logSafe("✅ Device token registered successfully.");
|
||||
return true;
|
||||
}
|
||||
logSafe("⚠️ Failed to register device token: ${data?['message']}",
|
||||
logSafe("⚠️ Failed to register device token: ${response?['message']}",
|
||||
level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Handles user login with email/password.
|
||||
/// Returns error map on failure, or null on success.
|
||||
static Future<Map<String, String>?> loginUser(
|
||||
Map<String, dynamic> data) async {
|
||||
logSafe("Attempting login...");
|
||||
final responseData = await _post("/auth/app/login", data);
|
||||
if (responseData == null)
|
||||
final responseData = await _networkRequest(
|
||||
path: "/auth/app/login",
|
||||
method: _HttpMethod.post,
|
||||
body: data,
|
||||
);
|
||||
|
||||
if (responseData == null) {
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
}
|
||||
|
||||
if (responseData['data'] != null) {
|
||||
await _handleLoginSuccess(responseData['data']);
|
||||
@ -76,9 +108,10 @@ class AuthService {
|
||||
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
||||
}
|
||||
|
||||
/// Refreshes the JWT access token using the refresh token.
|
||||
static Future<bool> refreshToken() async {
|
||||
final accessToken = LocalStorage.getJwtToken();
|
||||
final refreshToken = LocalStorage.getRefreshToken();
|
||||
final accessToken = await LocalStorage.getJwtToken();
|
||||
final refreshToken = await LocalStorage.getRefreshToken();
|
||||
|
||||
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
|
||||
logSafe("Missing access or refresh token.", level: LogLevel.warning);
|
||||
@ -86,14 +119,19 @@ class AuthService {
|
||||
}
|
||||
|
||||
final body = {"token": accessToken, "refreshToken": refreshToken};
|
||||
final data = await _post("/auth/refresh-token", body);
|
||||
if (data != null && data['success'] == true) {
|
||||
final data = await _networkRequest(
|
||||
path: "/auth/refresh-token",
|
||||
method: _HttpMethod.post,
|
||||
body: body,
|
||||
);
|
||||
|
||||
if (data != null && data['success'] == true && data['data'] != null) {
|
||||
await LocalStorage.setJwtToken(data['data']['token']);
|
||||
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
logSafe("Token refreshed successfully.");
|
||||
|
||||
final newFcmToken = LocalStorage.getFcmToken();
|
||||
final newFcmToken = await LocalStorage.getFcmToken();
|
||||
if (newFcmToken?.isNotEmpty ?? false) {
|
||||
await registerDeviceToken(newFcmToken!);
|
||||
}
|
||||
@ -104,35 +142,29 @@ class AuthService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Initiates the forgot password process.
|
||||
static Future<Map<String, String>?> forgotPassword(String email) =>
|
||||
_wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}),
|
||||
_wrapErrorHandling(
|
||||
() => _networkRequest(
|
||||
path: "/auth/forgot-password",
|
||||
method: _HttpMethod.post,
|
||||
body: {"email": email},
|
||||
),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "Failed to send reset link.");
|
||||
|
||||
static Future<Map<String, String>?> requestDemo(
|
||||
Map<String, dynamic> demoData) =>
|
||||
_wrapErrorHandling(() => _post("/market/inquiry", demoData),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "Failed to submit demo request.");
|
||||
|
||||
static Future<List<Map<String, dynamic>>?> getIndustries() async {
|
||||
final data = await _get("/market/industries");
|
||||
if (data != null && data['success'] == true) {
|
||||
return List<Map<String, dynamic>>.from(data['data']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Generates an MPIN for the user.
|
||||
static Future<Map<String, String>?> generateMpin({
|
||||
required String employeeId,
|
||||
required String mpin,
|
||||
}) =>
|
||||
_wrapErrorHandling(
|
||||
() async {
|
||||
final token = LocalStorage.getJwtToken();
|
||||
return _post(
|
||||
"/auth/generate-mpin",
|
||||
{"employeeId": employeeId, "mpin": mpin},
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
return _networkRequest(
|
||||
path: "/auth/generate-mpin",
|
||||
method: _HttpMethod.post,
|
||||
body: {"employeeId": employeeId, "mpin": mpin},
|
||||
authToken: token,
|
||||
);
|
||||
},
|
||||
@ -140,6 +172,7 @@ class AuthService {
|
||||
defaultError: "Failed to generate MPIN.",
|
||||
);
|
||||
|
||||
/// Verifies the MPIN for quick login.
|
||||
static Future<Map<String, String>?> verifyMpin({
|
||||
required String mpin,
|
||||
required String mpinToken,
|
||||
@ -147,12 +180,14 @@ class AuthService {
|
||||
}) =>
|
||||
_wrapErrorHandling(
|
||||
() async {
|
||||
final employeeInfo = LocalStorage.getEmployeeInfo();
|
||||
if (employeeInfo == null) return null;
|
||||
final employeeInfo = await LocalStorage.getEmployeeInfo();
|
||||
if (employeeInfo == null) return null; // Fails immediately if info is missing
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
return _post(
|
||||
"/auth/login-mpin",
|
||||
{
|
||||
|
||||
final responseData = await _networkRequest(
|
||||
path: "/auth/login-mpin",
|
||||
method: _HttpMethod.post,
|
||||
body: {
|
||||
"employeeId": employeeInfo.id,
|
||||
"mpin": mpin,
|
||||
"mpinToken": mpinToken,
|
||||
@ -160,21 +195,41 @@ class AuthService {
|
||||
},
|
||||
authToken: token,
|
||||
);
|
||||
|
||||
// Handle token updates from MPIN login success if necessary,
|
||||
// though typically refresh or a separate login handles this.
|
||||
if (responseData?['data'] != null) {
|
||||
await _handleLoginSuccess(responseData!['data']);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
},
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "MPIN verification failed.",
|
||||
);
|
||||
|
||||
/// Generates an OTP for login/verification.
|
||||
static Future<Map<String, String>?> generateOtp(String email) =>
|
||||
_wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}),
|
||||
_wrapErrorHandling(
|
||||
() => _networkRequest(
|
||||
path: "/auth/send-otp",
|
||||
method: _HttpMethod.post,
|
||||
body: {"email": email},
|
||||
),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "Failed to generate OTP.");
|
||||
|
||||
/// Verifies the OTP and completes the login process.
|
||||
static Future<Map<String, String>?> verifyOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
}) async {
|
||||
final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
|
||||
final data = await _networkRequest(
|
||||
path: "/auth/login-otp",
|
||||
method: _HttpMethod.post,
|
||||
body: {"email": email, "otp": otp},
|
||||
);
|
||||
|
||||
if (data != null && data['data'] != null) {
|
||||
await _handleLoginSuccess(data['data']);
|
||||
return null;
|
||||
@ -182,61 +237,297 @@ class AuthService {
|
||||
return {"error": data?['message'] ?? "OTP verification failed."};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Private Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* MARKET/OTHER METHODS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
static Future<Map<String, dynamic>?> _post(
|
||||
String path,
|
||||
Map<String, dynamic> body, {
|
||||
/// Submits a demo request to the market endpoint.
|
||||
static Future<Map<String, String>?> requestDemo(
|
||||
Map<String, dynamic> demoData) =>
|
||||
_wrapErrorHandling(
|
||||
() => _networkRequest(
|
||||
path: "/market/inquiry",
|
||||
method: _HttpMethod.post,
|
||||
body: demoData,
|
||||
),
|
||||
successCondition: (data) => data['success'] == true,
|
||||
defaultError: "Failed to submit demo request.");
|
||||
|
||||
/// Fetches the list of available industries.
|
||||
static Future<List<Map<String, dynamic>>?> getIndustries() async {
|
||||
final data = await _networkRequest(
|
||||
path: "/market/industries",
|
||||
method: _HttpMethod.get,
|
||||
);
|
||||
if (data != null && data['success'] == true && data['data'] is List) {
|
||||
return List<Map<String, dynamic>>.from(data['data']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* TENANT METHODS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
static void setSelectedTenant(Tenant tenant) {
|
||||
currentTenant = tenant;
|
||||
}
|
||||
|
||||
static bool get isTenantSelected => currentTenant != null;
|
||||
|
||||
/// Fetches the list of tenants the user belongs to.
|
||||
static Future<List<Map<String, dynamic>>?> getTenants(
|
||||
{bool hasRetried = false}) async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
if (token == null) {
|
||||
await _handleUnauthorized();
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = await _networkRequest(
|
||||
path: "/auth/get/user/tenants",
|
||||
method: _HttpMethod.get,
|
||||
authToken: token,
|
||||
);
|
||||
|
||||
if (data != null && data['success'] == true && data['data'] is List) {
|
||||
return List<Map<String, dynamic>>.from(data['data']);
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized via refreshToken/retry logic
|
||||
if (data?['statusCode'] == 401 && !hasRetried) {
|
||||
final refreshed = await refreshToken();
|
||||
if (refreshed) return getTenants(hasRetried: true);
|
||||
}
|
||||
|
||||
// Fallback on all other failures
|
||||
if (data != null && data['statusCode'] != 401) {
|
||||
_handleApiError(
|
||||
data['statusCode'], data, "Fetching tenants");
|
||||
} else if (data?['statusCode'] == 401 && hasRetried) {
|
||||
await _handleUnauthorized();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Selects a specific tenant, updating the JWT and refresh tokens.
|
||||
static Future<bool> selectTenant(String tenantId,
|
||||
{bool hasRetried = false}) async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
if (token == null) {
|
||||
await _handleUnauthorized();
|
||||
return false;
|
||||
}
|
||||
|
||||
final data = await _networkRequest(
|
||||
path: "/auth/select-tenant/$tenantId",
|
||||
method: _HttpMethod.post,
|
||||
authToken: token,
|
||||
);
|
||||
|
||||
if (data != null && data['success'] == true && data['data'] != null) {
|
||||
await LocalStorage.setJwtToken(data['data']['token']);
|
||||
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||
logSafe("✅ Tenant selected successfully. Tokens updated.");
|
||||
|
||||
// Refresh project controller data
|
||||
try {
|
||||
final projectController = Get.find<ProjectController>();
|
||||
projectController.clearProjects();
|
||||
projectController.fetchProjects();
|
||||
} catch (_) {
|
||||
logSafe("⚠️ ProjectController not found while refreshing projects");
|
||||
}
|
||||
|
||||
// Re-register FCM token with new tenant context
|
||||
final fcmToken = await LocalStorage.getFcmToken();
|
||||
if (fcmToken?.isNotEmpty ?? false) {
|
||||
await registerDeviceToken(fcmToken!);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized via refreshToken/retry logic
|
||||
if (data?['statusCode'] == 401 && !hasRetried) {
|
||||
final refreshed = await refreshToken();
|
||||
if (refreshed) return selectTenant(tenantId, hasRetried: true);
|
||||
await _handleUnauthorized();
|
||||
}
|
||||
|
||||
// Fallback on all other failures
|
||||
if (data != null) {
|
||||
_handleApiError(data['statusCode'], data, "Selecting tenant");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* PERMISSION/USER METHODS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// Fetches all user-related data (permissions, employee info, projects).
|
||||
static Future<Map<String, dynamic>> fetchAllUserData(
|
||||
String token, {
|
||||
bool hasRetried = false,
|
||||
}) async {
|
||||
logSafe("Fetching user data...");
|
||||
|
||||
final cached = _userDataCache[token];
|
||||
if (cached != null) {
|
||||
logSafe("User data cache hit.");
|
||||
return cached;
|
||||
}
|
||||
|
||||
final data = await _networkRequest(
|
||||
path: "/user/profile",
|
||||
method: _HttpMethod.get,
|
||||
authToken: token,
|
||||
);
|
||||
|
||||
if (data != null && data['success'] == true && data['data'] is Map<String, dynamic>) {
|
||||
final responseData = data['data'] as Map<String, dynamic>;
|
||||
|
||||
final result = {
|
||||
'permissions': _parsePermissions(responseData['featurePermissions']),
|
||||
'employeeInfo': await _parseEmployeeInfo(responseData['employeeInfo']),
|
||||
'projects': _parseProjectsInfo(responseData['projects']),
|
||||
};
|
||||
|
||||
_userDataCache[token] = result;
|
||||
logSafe("User data fetched and decrypted successfully.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized via refreshToken/retry logic
|
||||
if (data?['statusCode'] == 401 && !hasRetried) {
|
||||
final refreshed = await refreshToken();
|
||||
final newToken = await LocalStorage.getJwtToken();
|
||||
if (refreshed && newToken != null) {
|
||||
return fetchAllUserData(newToken, hasRetried: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle failure and unauthorized
|
||||
if (data?['statusCode'] == 401 || data?['statusCode'] == 403 || data == null) {
|
||||
await _handleUnauthorized();
|
||||
throw Exception('Unauthorized or Network Error. Token refresh failed.');
|
||||
}
|
||||
|
||||
final errorMsg = data['message'] ?? 'Unknown error';
|
||||
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
|
||||
throw Exception('Failed to fetch user data: $errorMsg');
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Private Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// Global handler for unauthorized access, clears tokens and redirects.
|
||||
static Future<void> _handleUnauthorized() async {
|
||||
logSafe(
|
||||
"Clearing tokens and redirecting to login due to unauthorized access.",
|
||||
level: LogLevel.warning);
|
||||
await LocalStorage.removeToken('jwt_token');
|
||||
await LocalStorage.removeToken('refresh_token');
|
||||
await LocalStorage.setLoggedInUser(false);
|
||||
isLoggedIn = false;
|
||||
Get.offAllNamed('/auth/login-option');
|
||||
}
|
||||
|
||||
/// Parses raw permission list into a list of UserPermission models.
|
||||
static List<UserPermission> _parsePermissions(List<dynamic>? permissions) {
|
||||
logSafe("Parsing user permissions...");
|
||||
if (permissions == null) return [];
|
||||
return permissions
|
||||
.map((perm) => UserPermission.fromJson({'id': perm}))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Parses raw employee info, stores it locally, and returns the model.
|
||||
static Future<EmployeeInfo> _parseEmployeeInfo(
|
||||
Map<String, dynamic>? data) async {
|
||||
logSafe("Parsing employee info...");
|
||||
if (data == null) throw Exception("Employee data missing");
|
||||
final employeeInfo = EmployeeInfo.fromJson(data);
|
||||
await LocalStorage.setEmployeeInfo(employeeInfo);
|
||||
return employeeInfo;
|
||||
}
|
||||
|
||||
/// Parses raw projects list into a list of ProjectInfo models.
|
||||
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
|
||||
logSafe("Parsing projects info...");
|
||||
if (projects == null) return [];
|
||||
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
|
||||
}
|
||||
|
||||
/// Internal utility to report API errors.
|
||||
static void _handleApiError(
|
||||
int statusCode, Map<String, dynamic> data, String context) {
|
||||
final message = data['message'] ?? 'Unknown error';
|
||||
final level = statusCode >= 500 ? LogLevel.error : LogLevel.warning;
|
||||
logSafe("❌ $context failed: $message [Status: $statusCode]", level: level);
|
||||
}
|
||||
|
||||
|
||||
/// General network request handler for both GET and POST.
|
||||
static Future<Map<String, dynamic>?> _networkRequest({
|
||||
required String path,
|
||||
required _HttpMethod method,
|
||||
Map<String, dynamic>? body,
|
||||
String? authToken,
|
||||
}) async {
|
||||
try {
|
||||
final headers = {
|
||||
..._headers,
|
||||
if (authToken?.isNotEmpty ?? false)
|
||||
'Authorization': 'Bearer $authToken',
|
||||
};
|
||||
final response = await http.post(Uri.parse("$_baseUrl$path"),
|
||||
headers: headers, body: jsonEncode(body));
|
||||
final uri = Uri.parse("$_baseUrl$path");
|
||||
final headers = {
|
||||
..._defaultHeaders,
|
||||
if (authToken?.isNotEmpty ?? false) 'Authorization': 'Bearer $authToken',
|
||||
};
|
||||
|
||||
final decrypted = decryptResponse(response.body); // <-- Decrypt here
|
||||
if (decrypted is Map<String, dynamic>) {
|
||||
return {"statusCode": response.statusCode, ...decrypted};
|
||||
} else {
|
||||
return {"statusCode": response.statusCode, "data": decrypted};
|
||||
http.Response? response;
|
||||
try {
|
||||
logSafe(
|
||||
"➡️ ${method.name.toUpperCase()} $_baseUrl$path${body != null ? '\nBody: ${jsonEncode(body)}' : ''}",
|
||||
level: LogLevel.info);
|
||||
|
||||
if (method == _HttpMethod.post) {
|
||||
response = await http.post(uri, headers: headers, body: jsonEncode(body));
|
||||
} else { // GET
|
||||
response = await http.get(uri, headers: headers);
|
||||
}
|
||||
|
||||
if (response.body.isEmpty || response.body.trim().isEmpty) {
|
||||
logSafe("❌ Empty response for $path", level: LogLevel.error);
|
||||
// Special case for unauthorized response with no body (e.g., gateway issue)
|
||||
if (response.statusCode == 401) {
|
||||
await _handleUnauthorized();
|
||||
}
|
||||
return {"statusCode": response.statusCode, "success": false, "message": "Empty response body"};
|
||||
}
|
||||
|
||||
final decrypted = decryptResponse(response.body);
|
||||
|
||||
if (decrypted == null) {
|
||||
logSafe("❌ Response decryption failed for $path", level: LogLevel.error);
|
||||
return {"statusCode": response.statusCode, "success": false, "message": "Failed to decrypt response"};
|
||||
}
|
||||
|
||||
final Map<String, dynamic> result = decrypted is Map<String, dynamic>
|
||||
? decrypted
|
||||
: {"data": decrypted}; // Wrap non-map responses
|
||||
|
||||
logSafe(
|
||||
"⬅️ Response: ${jsonEncode(result)} [Status: ${response.statusCode}]",
|
||||
level: LogLevel.info);
|
||||
|
||||
return {"statusCode": response.statusCode, ...result};
|
||||
|
||||
} catch (e, st) {
|
||||
_handleError("$path POST error", e, st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> _get(
|
||||
String path, {
|
||||
String? authToken,
|
||||
}) async {
|
||||
try {
|
||||
final headers = {
|
||||
..._headers,
|
||||
if (authToken?.isNotEmpty ?? false)
|
||||
'Authorization': 'Bearer $authToken',
|
||||
};
|
||||
final response =
|
||||
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
|
||||
|
||||
final decrypted = decryptResponse(response.body); // <-- Decrypt here
|
||||
if (decrypted is Map<String, dynamic>) {
|
||||
return {"statusCode": response.statusCode, ...decrypted};
|
||||
} else {
|
||||
return {"statusCode": response.statusCode, "data": decrypted};
|
||||
}
|
||||
} catch (e, st) {
|
||||
_handleError("$path GET error", e, st);
|
||||
_handleError("$path ${method.name.toUpperCase()} error", e, st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility to wrap simple API calls with error-to-UI message mapping.
|
||||
static Future<Map<String, String>?> _wrapErrorHandling(
|
||||
Future<Map<String, dynamic>?> Function() request, {
|
||||
required bool Function(Map<String, dynamic> data) successCondition,
|
||||
@ -247,10 +538,12 @@ class AuthService {
|
||||
return {"error": data?['message'] ?? defaultError};
|
||||
}
|
||||
|
||||
/// Generic error logging helper.
|
||||
static void _handleError(String message, Object error, StackTrace st) {
|
||||
logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
|
||||
}
|
||||
|
||||
/// Common logic for storing tokens and login state upon successful authentication.
|
||||
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
|
||||
await LocalStorage.setJwtToken(data['token']);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
@ -268,4 +561,4 @@ class AuthService {
|
||||
}
|
||||
isLoggedIn = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,120 +0,0 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||
import 'package:on_field_work/model/user_permission.dart';
|
||||
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||
import 'package:on_field_work/model/projects_model.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
||||
import 'package:on_field_work/helpers/utils/encryption_helper.dart';
|
||||
|
||||
class PermissionService {
|
||||
static final Map<String, Map<String, dynamic>> _userDataCache = {};
|
||||
static const String _baseUrl = ApiEndpoints.baseUrl;
|
||||
|
||||
static Future<Map<String, dynamic>> fetchAllUserData(
|
||||
String token, {
|
||||
bool hasRetried = false,
|
||||
}) async {
|
||||
logSafe("Fetching user data...");
|
||||
|
||||
final cached = _userDataCache[token];
|
||||
if (cached != null) {
|
||||
logSafe("User data cache hit.");
|
||||
return cached;
|
||||
}
|
||||
|
||||
final uri = Uri.parse("$_baseUrl/user/profile");
|
||||
final headers = {'Authorization': 'Bearer $token'};
|
||||
|
||||
try {
|
||||
final response = await http.get(uri, headers: headers);
|
||||
final statusCode = response.statusCode;
|
||||
|
||||
if (response.body.isEmpty || response.body.trim().isEmpty) {
|
||||
logSafe("❌ Empty user data response — auto logout");
|
||||
await _handleUnauthorized();
|
||||
throw Exception("Empty user data response");
|
||||
}
|
||||
|
||||
final decrypted = decryptResponse(response.body);
|
||||
if (decrypted == null) {
|
||||
logSafe("❌ Failed to decrypt user data — auto logout", level: LogLevel.error);
|
||||
await _handleUnauthorized();
|
||||
throw Exception("Decryption failed for user data");
|
||||
}
|
||||
|
||||
final data = decrypted is Map ? decrypted['data'] ?? decrypted : null;
|
||||
if (data == null || data is! Map<String, dynamic>) {
|
||||
logSafe("❌ Decrypted user data is invalid — auto logout", level: LogLevel.error);
|
||||
await _handleUnauthorized();
|
||||
throw Exception("Invalid decrypted user data");
|
||||
}
|
||||
|
||||
if (statusCode == 200) {
|
||||
final result = {
|
||||
'permissions': _parsePermissions(data['featurePermissions']),
|
||||
'employeeInfo': _parseEmployeeInfo(data['employeeInfo']),
|
||||
'projects': _parseProjectsInfo(data['projects']),
|
||||
};
|
||||
|
||||
_userDataCache[token] = result;
|
||||
logSafe("User data fetched and decrypted successfully.");
|
||||
return result;
|
||||
}
|
||||
|
||||
if (statusCode == 401 && !hasRetried) {
|
||||
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
|
||||
|
||||
final refreshed = await AuthService.refreshToken();
|
||||
if (refreshed) {
|
||||
final newToken = await LocalStorage.getJwtToken();
|
||||
if (newToken != null && newToken.isNotEmpty) {
|
||||
return fetchAllUserData(newToken, hasRetried: true);
|
||||
}
|
||||
}
|
||||
|
||||
await _handleUnauthorized();
|
||||
logSafe("Token refresh failed. Redirecting to login.", level: LogLevel.warning);
|
||||
throw Exception('Unauthorized. Token refresh failed.');
|
||||
}
|
||||
|
||||
final errorMsg = data['message'] ?? 'Unknown error';
|
||||
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
|
||||
throw Exception('Failed to fetch user data: $errorMsg');
|
||||
} catch (e, stacktrace) {
|
||||
logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
static Future<void> _handleUnauthorized() async {
|
||||
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
|
||||
await LocalStorage.removeToken('jwt_token');
|
||||
await LocalStorage.removeToken('refresh_token');
|
||||
await LocalStorage.setLoggedInUser(false);
|
||||
Get.offAllNamed('/auth/login-option');
|
||||
}
|
||||
|
||||
static List<UserPermission> _parsePermissions(List<dynamic>? permissions) {
|
||||
logSafe("Parsing user permissions...");
|
||||
if (permissions == null) return [];
|
||||
return permissions.map((perm) => UserPermission.fromJson({'id': perm})).toList();
|
||||
}
|
||||
|
||||
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? data) {
|
||||
logSafe("Parsing employee info...");
|
||||
if (data == null) throw Exception("Employee data missing");
|
||||
return EmployeeInfo.fromJson(data);
|
||||
}
|
||||
|
||||
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
|
||||
logSafe("Parsing projects info...");
|
||||
if (projects == null) return [];
|
||||
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
|
||||
}
|
||||
}
|
||||
@ -1,155 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:on_field_work/controller/project_controller.dart';
|
||||
import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
||||
import 'package:on_field_work/helpers/utils/encryption_helper.dart';
|
||||
|
||||
abstract class ITenantService {
|
||||
Future<List<Map<String, dynamic>>?> getTenants({bool hasRetried = false});
|
||||
Future<bool> selectTenant(String tenantId, {bool hasRetried = false});
|
||||
}
|
||||
|
||||
class TenantService implements ITenantService {
|
||||
static const String _baseUrl = ApiEndpoints.baseUrl;
|
||||
static const Map<String, String> _headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
static Tenant? currentTenant;
|
||||
|
||||
static void setSelectedTenant(Tenant tenant) {
|
||||
currentTenant = tenant;
|
||||
}
|
||||
|
||||
static bool get isTenantSelected => currentTenant != null;
|
||||
|
||||
static Future<Map<String, String>> _authorizedHeaders() async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
if (token == null || token.isEmpty) throw Exception('Missing JWT token');
|
||||
return {..._headers, 'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
static void _handleApiError(http.Response response, dynamic data, String context) {
|
||||
final message = data['message'] ?? 'Unknown error';
|
||||
final level = response.statusCode >= 500 ? LogLevel.error : LogLevel.warning;
|
||||
logSafe("❌ $context failed: $message [Status: ${response.statusCode}]", level: level);
|
||||
}
|
||||
|
||||
static void _logException(dynamic e, dynamic st, String context) {
|
||||
logSafe("❌ $context exception", level: LogLevel.error, error: e, stackTrace: st);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>?> getTenants({bool hasRetried = false}) async {
|
||||
try {
|
||||
final headers = await _authorizedHeaders();
|
||||
final response = await http.get(Uri.parse("$_baseUrl/auth/get/user/tenants"), headers: headers);
|
||||
|
||||
if (response.body.isEmpty || response.body.trim().isEmpty) {
|
||||
logSafe("❌ Empty tenant response — auto logout");
|
||||
await LocalStorage.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
final decrypted = decryptResponse(response.body);
|
||||
if (decrypted == null) {
|
||||
logSafe("❌ Tenant response decryption failed — auto logout");
|
||||
await LocalStorage.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = decrypted is Map ? decrypted : null;
|
||||
if (data == null) {
|
||||
logSafe("❌ Decrypted tenant data is not valid JSON — auto logout");
|
||||
await LocalStorage.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 && data['success'] == true) {
|
||||
final list = data['data'];
|
||||
if (list is! List) return null;
|
||||
return List<Map<String, dynamic>>.from(list);
|
||||
}
|
||||
|
||||
if (response.statusCode == 401 && !hasRetried) {
|
||||
final refreshed = await AuthService.refreshToken();
|
||||
if (refreshed) return getTenants(hasRetried: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
_handleApiError(response, data, "Fetching tenants");
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
_logException(e, st, "Get Tenants API");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> selectTenant(String tenantId, {bool hasRetried = false}) async {
|
||||
try {
|
||||
final headers = await _authorizedHeaders();
|
||||
logSafe("➡️ POST $_baseUrl/auth/select-tenant/$tenantId\nHeaders: $headers", level: LogLevel.info);
|
||||
|
||||
final response = await http.post(Uri.parse("$_baseUrl/auth/select-tenant/$tenantId"), headers: headers);
|
||||
final decrypted = decryptResponse(response.body);
|
||||
if (decrypted == null) {
|
||||
logSafe("❌ Tenant selection response decryption failed", level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
final data = decrypted is Map ? decrypted : null;
|
||||
if (data == null) {
|
||||
logSafe("❌ Decrypted tenant selection data is not valid JSON", level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
logSafe("⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]", level: LogLevel.info);
|
||||
|
||||
if (response.statusCode == 200 && data['success'] == true) {
|
||||
await LocalStorage.setJwtToken(data['data']['token']);
|
||||
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||
logSafe("✅ Tenant selected successfully. Tokens updated.");
|
||||
|
||||
try {
|
||||
final projectController = Get.find<ProjectController>();
|
||||
projectController.clearProjects();
|
||||
projectController.fetchProjects();
|
||||
} catch (_) {
|
||||
logSafe("⚠️ ProjectController not found while refreshing projects");
|
||||
}
|
||||
|
||||
final fcmToken = LocalStorage.getFcmToken();
|
||||
if (fcmToken?.isNotEmpty ?? false) {
|
||||
final success = await AuthService.registerDeviceToken(fcmToken!);
|
||||
logSafe(success ? "✅ FCM token registered after tenant selection." : "⚠️ Failed to register FCM token.", level: success ? LogLevel.info : LogLevel.warning);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 401 && !hasRetried) {
|
||||
logSafe("⚠️ Unauthorized while selecting tenant. Refreshing token...", level: LogLevel.warning);
|
||||
final refreshed = await AuthService.refreshToken();
|
||||
if (refreshed) return selectTenant(tenantId, hasRetried: true);
|
||||
logSafe("❌ Token refresh failed while selecting tenant.", level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
_handleApiError(response, data, "Selecting tenant");
|
||||
return false;
|
||||
} catch (e, st) {
|
||||
_logException(e, st, "Select Tenant API");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
||||
import 'package:on_field_work/view/auth/forgot_password_screen.dart';
|
||||
import 'package:on_field_work/view/auth/login_screen.dart';
|
||||
import 'package:on_field_work/view/auth/register_account_screen.dart';
|
||||
@ -32,7 +31,7 @@ class AuthMiddleware extends GetMiddleware {
|
||||
if (route != '/auth/login-option') {
|
||||
return const RouteSettings(name: '/auth/login-option');
|
||||
}
|
||||
} else if (!TenantService.isTenantSelected) {
|
||||
} else if (!AuthService.isTenantSelected) {
|
||||
if (route != '/select-tenant') {
|
||||
return const RouteSettings(name: '/select-tenant');
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import 'package:on_field_work/model/employees/employee_info.dart';
|
||||
import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
||||
import 'package:on_field_work/images.dart';
|
||||
import 'package:on_field_work/view/layouts/user_profile_right_bar.dart';
|
||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||
|
||||
class Layout extends StatefulWidget {
|
||||
@ -106,7 +106,7 @@ class _LayoutState extends State<Layout> with UIMixin {
|
||||
}
|
||||
|
||||
Widget _buildHeaderContent(bool isMobile) {
|
||||
final selectedTenant = TenantService.currentTenant;
|
||||
final selectedTenant = AuthService.currentTenant;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
|
||||
|
||||
@ -11,7 +11,7 @@ import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||
import 'package:on_field_work/controller/auth/mpin_controller.dart';
|
||||
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
|
||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||
import 'package:on_field_work/view/tenant/tenant_selection_screen.dart';
|
||||
import 'package:on_field_work/controller/tenant/tenant_switch_controller.dart';
|
||||
import 'package:on_field_work/helpers/theme/theme_editor_widget.dart';
|
||||
@ -285,7 +285,7 @@ class _UserProfileBarState extends State<UserProfileBar>
|
||||
);
|
||||
}
|
||||
|
||||
final selectedTenant = TenantService.currentTenant;
|
||||
final selectedTenant = AuthService.currentTenant;
|
||||
|
||||
final sortedTenants = List.of(tenants);
|
||||
if (selectedTenant != null) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user