This commit is contained in:
Phuoc Nguyen
2025-11-10 14:21:27 +07:00
parent 2a71c65577
commit 36bdf6613b
33 changed files with 2206 additions and 252 deletions

View File

@@ -0,0 +1,28 @@
/// Frappe Auth Provider
///
/// Riverpod provider for FrappeAuthService.
library;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/services/frappe_auth_service.dart';
part 'frappe_auth_provider.g.dart';
/// Frappe Auth Service Provider
///
/// Provides singleton instance of FrappeAuthService.
@riverpod
FrappeAuthService frappeAuthService(Ref ref) {
// Create a separate Dio instance for Frappe auth
// (not using the main dio client to avoid circular dependencies)
final dio = Dio();
const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
return FrappeAuthService(dio, secureStorage);
}

View File

@@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'frappe_auth_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Frappe Auth Service Provider
///
/// Provides singleton instance of FrappeAuthService.
@ProviderFor(frappeAuthService)
const frappeAuthServiceProvider = FrappeAuthServiceProvider._();
/// Frappe Auth Service Provider
///
/// Provides singleton instance of FrappeAuthService.
final class FrappeAuthServiceProvider
extends
$FunctionalProvider<
FrappeAuthService,
FrappeAuthService,
FrappeAuthService
>
with $Provider<FrappeAuthService> {
/// Frappe Auth Service Provider
///
/// Provides singleton instance of FrappeAuthService.
const FrappeAuthServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'frappeAuthServiceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$frappeAuthServiceHash();
@$internal
@override
$ProviderElement<FrappeAuthService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
FrappeAuthService create(Ref ref) {
return frappeAuthService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(FrappeAuthService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<FrappeAuthService>(value),
);
}
}
String _$frappeAuthServiceHash() => r'73112c920895302df011517e81c97eef2b5df5ac';

View File

@@ -0,0 +1,245 @@
/// Frappe Authentication Service
///
/// Handles Frappe/ERPNext session management (sid and csrf_token).
/// Provides methods to get session, login, and manage session storage.
library;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/models/frappe_session_model.dart';
/// Frappe Auth Service
///
/// Manages Frappe session lifecycle:
/// 1. Get initial session (public API)
/// 2. Login with phone number
/// 3. Store sid and csrf_token in secure storage
/// 4. Provide session data for API requests
class FrappeAuthService {
FrappeAuthService(this._dio, this._secureStorage);
final Dio _dio;
final FlutterSecureStorage _secureStorage;
/// Storage keys for Frappe session
static const String _keyFrappeSid = 'frappe_sid';
static const String _keyFrappeCsrfToken = 'frappe_csrf_token';
static const String _keyFrappeFullName = 'frappe_full_name';
static const String _keyFrappeUserId = 'frappe_user_id';
/// Get Frappe session from API
///
/// This endpoint doesn't require authentication - it's public.
/// Returns initial session for subsequent API calls.
///
/// API: POST /api/method/dbiz_common.dbiz_common.api.auth.get_session
Future<FrappeSessionResponse> getSession() async {
try {
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetSession}';
final response = await _dio.post<Map<String, dynamic>>(
url,
data: '', // Empty data as per docs
);
if (response.data == null) {
throw Exception('Empty response from Frappe session API');
}
final sessionResponse = FrappeSessionResponse.fromJson(response.data!);
// Store session in secure storage
await _storeSession(
sid: sessionResponse.sid,
csrfToken: sessionResponse.csrfToken,
fullName: sessionResponse.fullName,
userId: ApiConstants.frappePublicUserId,
);
return sessionResponse;
} on DioException catch (e) {
throw Exception('Failed to get Frappe session: ${e.message}');
} catch (e) {
throw Exception('Unexpected error getting Frappe session: $e');
}
}
/// Login with phone number
///
/// Requires existing session (sid and csrf_token).
/// Returns new session with user's authentication.
///
/// API: POST /api/method/building_material.building_material.api.auth.login
/// Headers: Cookie (with sid), X-Frappe-Csrf-Token
/// Body: { "username": "phone", "googleid": null, "facebookid": null, "zaloid": null }
///
/// Note: Password not used yet, but field reserved for future use
Future<FrappeSessionResponse> login(String phone, {String? password}) async {
try {
// Ensure we have a session first
final session = await getStoredSession();
if (session == null) {
await getSession();
final newSession = await getStoredSession();
if (newSession == null) {
throw Exception('Failed to initialize session');
}
}
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
// Build cookie header
final storedSession = await getStoredSession();
final cookieHeader = _buildCookieHeader(
sid: storedSession!['sid']!,
fullName: storedSession['fullName']!,
userId: storedSession['userId']!,
);
final response = await _dio.post<Map<String, dynamic>>(
url,
data: {
'username': phone,
'googleid': null,
'facebookid': null,
'zaloid': null,
// Password field reserved for future use
// 'password': password,
},
options: Options(
headers: {
'Cookie': cookieHeader,
'X-Frappe-Csrf-Token': storedSession['csrfToken']!,
'Content-Type': 'application/json',
},
),
);
if (response.data == null) {
throw Exception('Empty response from login API');
}
final loginResponse = FrappeSessionResponse.fromJson(response.data!);
// Store new session after login
await _storeSession(
sid: loginResponse.sid,
csrfToken: loginResponse.csrfToken,
fullName: loginResponse.fullName,
userId: phone, // Use phone as userId after login
);
return loginResponse;
} on DioException catch (e) {
throw Exception('Login failed: ${e.message}');
} catch (e) {
throw Exception('Unexpected error during login: $e');
}
}
/// Store session in secure storage
Future<void> _storeSession({
required String sid,
required String csrfToken,
required String fullName,
required String userId,
}) async {
await Future.wait([
_secureStorage.write(key: _keyFrappeSid, value: sid),
_secureStorage.write(key: _keyFrappeCsrfToken, value: csrfToken),
_secureStorage.write(key: _keyFrappeFullName, value: fullName),
_secureStorage.write(key: _keyFrappeUserId, value: userId),
]);
}
/// Get stored session from secure storage
Future<Map<String, String>?> getStoredSession() async {
final results = await Future.wait([
_secureStorage.read(key: _keyFrappeSid),
_secureStorage.read(key: _keyFrappeCsrfToken),
_secureStorage.read(key: _keyFrappeFullName),
_secureStorage.read(key: _keyFrappeUserId),
]);
final sid = results[0];
final csrfToken = results[1];
final fullName = results[2];
final userId = results[3];
// Return null if session is incomplete
if (sid == null || csrfToken == null) {
return null;
}
return {
'sid': sid,
'csrfToken': csrfToken,
'fullName': fullName ?? 'User',
'userId': userId ?? ApiConstants.frappePublicUserId,
};
}
/// Ensure valid session exists, fetch new one if needed
Future<Map<String, String>> ensureSession() async {
var session = await getStoredSession();
if (session == null) {
// No session in storage, get a new one
await getSession();
session = await getStoredSession();
if (session == null) {
throw Exception('Failed to get session');
}
}
return session;
}
/// Check if session exists in storage
Future<bool> hasSession() async {
final sid = await _secureStorage.read(key: _keyFrappeSid);
final csrfToken = await _secureStorage.read(key: _keyFrappeCsrfToken);
return sid != null && csrfToken != null;
}
/// Build cookie header string
String _buildCookieHeader({
required String sid,
required String fullName,
required String userId,
}) {
return [
'sid=$sid',
'full_name=$fullName',
'system_user=no',
'user_id=${Uri.encodeComponent(userId)}',
'user_image=',
].join('; ');
}
/// Get headers for Frappe API requests
Future<Map<String, String>> getHeaders() async {
final session = await ensureSession();
return {
'Cookie': _buildCookieHeader(
sid: session['sid']!,
fullName: session['fullName']!,
userId: session['userId']!,
),
'X-Frappe-Csrf-Token': session['csrfToken']!,
'Content-Type': 'application/json',
};
}
/// Clear stored session
Future<void> clearSession() async {
await Future.wait([
_secureStorage.delete(key: _keyFrappeSid),
_secureStorage.delete(key: _keyFrappeCsrfToken),
_secureStorage.delete(key: _keyFrappeFullName),
_secureStorage.delete(key: _keyFrappeUserId),
]);
}
}