564 lines
20 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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