564 lines
20 KiB
Dart
564 lines
20 KiB
Dart
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> _defaultHeaders = {
|
||
'Content-Type': 'application/json',
|
||
};
|
||
|
||
// AuthService properties
|
||
static bool isLoggedIn = false;
|
||
|
||
// 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 _networkRequest(
|
||
path: "/auth/logout",
|
||
method: _HttpMethod.post,
|
||
body: body,
|
||
);
|
||
|
||
if (response != null && response['statusCode'] == 200) {
|
||
logSafe("✅ Logout API successful");
|
||
return true;
|
||
}
|
||
|
||
logSafe("⚠️ Logout API failed: ${response?['message']}",
|
||
level: LogLevel.warning);
|
||
return false;
|
||
} catch (e, st) {
|
||
_handleError("Logout API error", e, st);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 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) {
|
||
logSafe("❌ Cannot register device token: missing JWT token",
|
||
level: LogLevel.warning);
|
||
return false;
|
||
}
|
||
|
||
final body = {"fcmToken": fcmToken};
|
||
final response = await _networkRequest(
|
||
path: "/auth/set/device-token",
|
||
method: _HttpMethod.post,
|
||
body: body,
|
||
authToken: token,
|
||
);
|
||
|
||
if (response != null && response['success'] == true) {
|
||
logSafe("✅ Device token registered successfully.");
|
||
return true;
|
||
}
|
||
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 _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']);
|
||
return null;
|
||
}
|
||
if (responseData['statusCode'] == 401) {
|
||
return {"password": "Invalid email or password"};
|
||
}
|
||
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
||
}
|
||
|
||
/// Refreshes the JWT access token using the refresh token.
|
||
static Future<bool> refreshToken() async {
|
||
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);
|
||
return false;
|
||
}
|
||
|
||
final body = {"token": accessToken, "refreshToken": refreshToken};
|
||
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 = await LocalStorage.getFcmToken();
|
||
if (newFcmToken?.isNotEmpty ?? false) {
|
||
await registerDeviceToken(newFcmToken!);
|
||
}
|
||
return true;
|
||
}
|
||
logSafe("Refresh token failed: ${data?['message']}",
|
||
level: LogLevel.warning);
|
||
return false;
|
||
}
|
||
|
||
/// Initiates the forgot password process.
|
||
static Future<Map<String, String>?> forgotPassword(String email) =>
|
||
_wrapErrorHandling(
|
||
() => _networkRequest(
|
||
path: "/auth/forgot-password",
|
||
method: _HttpMethod.post,
|
||
body: {"email": email},
|
||
),
|
||
successCondition: (data) => data['success'] == true,
|
||
defaultError: "Failed to send reset link.");
|
||
|
||
/// Generates an MPIN for the user.
|
||
static Future<Map<String, String>?> generateMpin({
|
||
required String employeeId,
|
||
required String mpin,
|
||
}) =>
|
||
_wrapErrorHandling(
|
||
() async {
|
||
final token = await LocalStorage.getJwtToken();
|
||
return _networkRequest(
|
||
path: "/auth/generate-mpin",
|
||
method: _HttpMethod.post,
|
||
body: {"employeeId": employeeId, "mpin": mpin},
|
||
authToken: token,
|
||
);
|
||
},
|
||
successCondition: (data) => data['success'] == true,
|
||
defaultError: "Failed to generate MPIN.",
|
||
);
|
||
|
||
/// Verifies the MPIN for quick login.
|
||
static Future<Map<String, String>?> verifyMpin({
|
||
required String mpin,
|
||
required String mpinToken,
|
||
required String fcmToken,
|
||
}) =>
|
||
_wrapErrorHandling(
|
||
() async {
|
||
final employeeInfo = await LocalStorage.getEmployeeInfo();
|
||
if (employeeInfo == null) return null; // Fails immediately if info is missing
|
||
final token = await LocalStorage.getJwtToken();
|
||
|
||
final responseData = await _networkRequest(
|
||
path: "/auth/login-mpin",
|
||
method: _HttpMethod.post,
|
||
body: {
|
||
"employeeId": employeeInfo.id,
|
||
"mpin": mpin,
|
||
"mpinToken": mpinToken,
|
||
"fcmToken": fcmToken,
|
||
},
|
||
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(
|
||
() => _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 _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;
|
||
}
|
||
return {"error": data?['message'] ?? "OTP verification failed."};
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* MARKET/OTHER METHODS */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/// 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 {
|
||
final uri = Uri.parse("$_baseUrl$path");
|
||
final headers = {
|
||
..._defaultHeaders,
|
||
if (authToken?.isNotEmpty ?? false) 'Authorization': 'Bearer $authToken',
|
||
};
|
||
|
||
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 ${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,
|
||
required String defaultError,
|
||
}) async {
|
||
final data = await request();
|
||
if (data != null && successCondition(data)) return null;
|
||
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);
|
||
|
||
if (data['refreshToken'] != null) {
|
||
await LocalStorage.setRefreshToken(data['refreshToken']);
|
||
}
|
||
|
||
if (data['mpinToken']?.isNotEmpty ?? false) {
|
||
await LocalStorage.setMpinToken(data['mpinToken']);
|
||
await LocalStorage.setIsMpin(true);
|
||
} else {
|
||
await LocalStorage.setIsMpin(false);
|
||
await LocalStorage.removeMpinToken();
|
||
}
|
||
isLoggedIn = true;
|
||
}
|
||
} |