add auth
This commit is contained in:
@@ -41,13 +41,16 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
final _phoneController = TextEditingController(text: "0988111111");
|
||||
final _phoneController = TextEditingController(text: "0978113710");
|
||||
final _passwordController = TextEditingController(text: "123456");
|
||||
|
||||
// Focus nodes
|
||||
final _phoneFocusNode = FocusNode();
|
||||
final _passwordFocusNode = FocusNode();
|
||||
|
||||
// Remember me checkbox state
|
||||
bool _rememberMe = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
@@ -74,11 +77,12 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
.login(
|
||||
phoneNumber: _phoneController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
rememberMe: _rememberMe,
|
||||
);
|
||||
|
||||
// Check if login was successful
|
||||
final authState = ref.read(authProvider);
|
||||
authState.when(
|
||||
final authState = ref.read(authProvider)
|
||||
..when(
|
||||
data: (user) {
|
||||
if (user != null && mounted) {
|
||||
// Navigate to home on success
|
||||
@@ -402,7 +406,45 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
|
||||
// Remember Me Checkbox
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _rememberMe,
|
||||
onChanged: isLoading
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_rememberMe = value ?? false;
|
||||
});
|
||||
},
|
||||
activeColor: AppColors.primaryBlue,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: isLoading
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_rememberMe = !_rememberMe;
|
||||
});
|
||||
},
|
||||
child: const Text(
|
||||
'Ghi nhớ đăng nhập',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
// Login Button
|
||||
SizedBox(
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||
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/constants/api_constants.dart';
|
||||
import 'package:worker/core/network/dio_client.dart';
|
||||
import 'package:worker/core/services/frappe_auth_service.dart';
|
||||
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
|
||||
import 'package:worker/features/auth/data/datasources/auth_remote_datasource.dart';
|
||||
import 'package:worker/features/auth/data/models/auth_session_model.dart';
|
||||
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||
|
||||
@@ -30,6 +35,21 @@ AuthLocalDataSource authLocalDataSource(Ref ref) {
|
||||
return AuthLocalDataSource(secureStorage);
|
||||
}
|
||||
|
||||
/// Provide FrappeAuthService instance
|
||||
@riverpod
|
||||
Future<FrappeAuthService> frappeAuthService(Ref ref) async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
final secureStorage = ref.watch(secureStorageProvider);
|
||||
return FrappeAuthService(dio, secureStorage);
|
||||
}
|
||||
|
||||
/// Provide AuthRemoteDataSource instance
|
||||
@riverpod
|
||||
Future<AuthRemoteDataSource> authRemoteDataSource(Ref ref) async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
return AuthRemoteDataSource(dio);
|
||||
}
|
||||
|
||||
/// Authentication state result
|
||||
///
|
||||
/// Represents the result of authentication operations.
|
||||
@@ -56,19 +76,141 @@ class Auth extends _$Auth {
|
||||
AuthLocalDataSource get _localDataSource =>
|
||||
ref.read(authLocalDataSourceProvider);
|
||||
|
||||
/// Get Frappe auth service
|
||||
Future<FrappeAuthService> get _frappeAuthService async =>
|
||||
await ref.read(frappeAuthServiceProvider.future);
|
||||
|
||||
/// Get auth remote data source
|
||||
Future<AuthRemoteDataSource> get _remoteDataSource async =>
|
||||
await ref.read(authRemoteDataSourceProvider.future);
|
||||
|
||||
/// Initialize with saved session if available
|
||||
@override
|
||||
Future<User?> build() async {
|
||||
// Check for saved session in secure storage
|
||||
final session = await _localDataSource.getSession();
|
||||
if (session != null) {
|
||||
// User has saved session, create User entity
|
||||
// Simple initialization - just check if user is logged in
|
||||
// Don't call getSession() here to avoid ref disposal issues
|
||||
try {
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
|
||||
// Check if "Remember Me" was enabled
|
||||
final rememberMe = await _localDataSource.getRememberMe();
|
||||
|
||||
if (!rememberMe) {
|
||||
// User didn't check "Remember Me", don't restore session
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have a stored session
|
||||
final sid = await secureStorage.read(key: 'frappe_sid');
|
||||
final userId = await secureStorage.read(key: 'frappe_user_id');
|
||||
final fullName = await secureStorage.read(key: 'frappe_full_name');
|
||||
|
||||
if (sid != null && userId != null && userId != ApiConstants.frappePublicUserId) {
|
||||
// User is logged in and wants to be remembered, create User entity
|
||||
final now = DateTime.now();
|
||||
return User(
|
||||
userId: userId,
|
||||
phoneNumber: userId,
|
||||
fullName: fullName ?? 'User',
|
||||
email: '',
|
||||
role: UserRole.customer,
|
||||
status: UserStatus.active,
|
||||
loyaltyTier: LoyaltyTier.gold,
|
||||
totalPoints: 0,
|
||||
companyInfo: null,
|
||||
cccd: null,
|
||||
attachments: [],
|
||||
address: null,
|
||||
avatarUrl: null,
|
||||
referralCode: null,
|
||||
referredBy: null,
|
||||
erpnextCustomerId: null,
|
||||
createdAt: now.subtract(const Duration(days: 30)),
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Failed to check session, but don't prevent app from starting
|
||||
print('Failed to check saved session: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Login with phone number
|
||||
///
|
||||
/// Uses Frappe ERPNext API authentication flow:
|
||||
/// 1. Get current session from storage (should exist from app startup)
|
||||
/// 2. Call login API with phone number
|
||||
/// 3. Get new authenticated session with user credentials
|
||||
/// 4. Update FlutterSecureStorage with new session
|
||||
/// 5. Update Dio interceptors with new session for subsequent API calls
|
||||
/// 6. Save rememberMe preference if enabled
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [phoneNumber]: User's phone number (Vietnamese format)
|
||||
/// - [password]: User's password (reserved for future use, not sent yet)
|
||||
/// - [rememberMe]: If true, session will be restored on next app launch
|
||||
///
|
||||
/// Returns: Authenticated User object on success
|
||||
///
|
||||
/// Throws: Exception on authentication failure
|
||||
Future<void> login({
|
||||
required String phoneNumber,
|
||||
required String password,
|
||||
bool rememberMe = false,
|
||||
}) async {
|
||||
// Set loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
// Validation
|
||||
if (phoneNumber.isEmpty) {
|
||||
throw Exception('Số điện thoại không được để trống');
|
||||
}
|
||||
|
||||
final frappeService = await _frappeAuthService;
|
||||
final remoteDataSource = await _remoteDataSource;
|
||||
|
||||
// Get current session (should exist from app startup)
|
||||
final currentSession = await frappeService.getStoredSession();
|
||||
if (currentSession == null) {
|
||||
// If no session, get a new one
|
||||
await frappeService.getSession();
|
||||
final newSession = await frappeService.getStoredSession();
|
||||
if (newSession == null) {
|
||||
throw Exception('Failed to get session');
|
||||
}
|
||||
}
|
||||
|
||||
// Get stored session again
|
||||
final session = await frappeService.getStoredSession();
|
||||
if (session == null) {
|
||||
throw Exception('Session not available');
|
||||
}
|
||||
|
||||
// Call login API with current session
|
||||
final loginResponse = await remoteDataSource.login(
|
||||
phone: phoneNumber,
|
||||
csrfToken: session['csrfToken']!,
|
||||
sid: session['sid']!,
|
||||
password: password, // Reserved for future use
|
||||
);
|
||||
|
||||
// Update FlutterSecureStorage with new authenticated session
|
||||
await frappeService.login(phoneNumber, password: password);
|
||||
|
||||
// Save rememberMe preference
|
||||
await _localDataSource.saveRememberMe(rememberMe);
|
||||
|
||||
// Create and return User entity
|
||||
final now = DateTime.now();
|
||||
return User(
|
||||
userId: 'user_saved', // TODO: Get from API
|
||||
phoneNumber: '', // TODO: Get from saved user data
|
||||
fullName: session.fullName,
|
||||
email: '', // TODO: Get from saved user data
|
||||
userId: phoneNumber,
|
||||
phoneNumber: phoneNumber,
|
||||
fullName: loginResponse.fullName,
|
||||
email: '',
|
||||
role: UserRole.customer,
|
||||
status: UserStatus.active,
|
||||
loyaltyTier: LoyaltyTier.gold,
|
||||
@@ -81,94 +223,6 @@ class Auth extends _$Auth {
|
||||
referralCode: null,
|
||||
referredBy: null,
|
||||
erpnextCustomerId: null,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Login with phone number and password
|
||||
///
|
||||
/// Simulates ERPNext API authentication with mock response.
|
||||
/// Stores session data (SID, CSRF token) in Hive.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [phoneNumber]: User's phone number (Vietnamese format)
|
||||
/// - [password]: User's password
|
||||
///
|
||||
/// Returns: Authenticated User object on success
|
||||
///
|
||||
/// Throws: Exception on authentication failure
|
||||
Future<void> login({
|
||||
required String phoneNumber,
|
||||
required String password,
|
||||
}) async {
|
||||
// Set loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Simulate API call delay
|
||||
state = await AsyncValue.guard(() async {
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
|
||||
// Mock validation
|
||||
if (phoneNumber.isEmpty || password.isEmpty) {
|
||||
throw Exception('Số điện thoại và mật khẩu không được để trống');
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
throw Exception('Mật khẩu phải có ít nhất 6 ký tự');
|
||||
}
|
||||
|
||||
// Simulate API response matching ERPNext format
|
||||
final mockApiResponse = AuthSessionResponse(
|
||||
sessionExpired: 1,
|
||||
message: const LoginMessage(
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
sid: 'df7fd4e7ef1041aa3422b0ee861315ba8c28d4fe008a7d7e0e7e0e01',
|
||||
csrfToken: '6b6e37563854e951c36a7af4177956bb15ca469ca4f498b742648d70',
|
||||
apps: [
|
||||
AppInfo(
|
||||
appTitle: 'App nhân viên kinh doanh',
|
||||
appEndpoint: '/ecommerce/app-sales',
|
||||
appLogo:
|
||||
'https://assets.digitalbiz.com.vn/DBIZ_Internal/Logo/logo_app_sales.png',
|
||||
),
|
||||
],
|
||||
),
|
||||
homePage: '/apps',
|
||||
fullName: 'Tân Duy Nguyễn',
|
||||
);
|
||||
|
||||
// Save session data to Hive
|
||||
final sessionData = SessionData.fromAuthResponse(mockApiResponse);
|
||||
await _localDataSource.saveSession(sessionData);
|
||||
|
||||
// Create and return User entity
|
||||
final now = DateTime.now();
|
||||
return User(
|
||||
userId: 'user_${phoneNumber.replaceAll('+84', '')}',
|
||||
phoneNumber: phoneNumber,
|
||||
fullName: mockApiResponse.fullName,
|
||||
email: 'user@eurotile.vn',
|
||||
role: UserRole.customer,
|
||||
status: UserStatus.active,
|
||||
loyaltyTier: LoyaltyTier.gold,
|
||||
totalPoints: 1500,
|
||||
companyInfo: const CompanyInfo(
|
||||
name: 'Công ty TNHH XYZ',
|
||||
taxId: '0123456789',
|
||||
businessType: 'Xây dựng',
|
||||
),
|
||||
cccd: '001234567890',
|
||||
attachments: [],
|
||||
address: '123 Đường ABC, Quận 1, TP.HCM',
|
||||
avatarUrl: null,
|
||||
referralCode: 'REF${phoneNumber.replaceAll('+84', '').substring(0, 6)}',
|
||||
referredBy: null,
|
||||
erpnextCustomerId: null,
|
||||
createdAt: now.subtract(const Duration(days: 30)),
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
@@ -178,17 +232,20 @@ class Auth extends _$Auth {
|
||||
|
||||
/// Logout current user
|
||||
///
|
||||
/// Clears authentication state and removes saved session from Hive.
|
||||
/// Clears authentication state and removes saved session.
|
||||
/// Gets a new public session for registration/login.
|
||||
Future<void> logout() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
// Clear saved session from Hive
|
||||
final frappeService = await _frappeAuthService;
|
||||
|
||||
// Clear saved session
|
||||
await _localDataSource.clearSession();
|
||||
await frappeService.clearSession();
|
||||
|
||||
// TODO: Call logout API to invalidate token on server
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
// Get new public session for registration/login
|
||||
await frappeService.getSession();
|
||||
|
||||
// Return null to indicate logged out
|
||||
return null;
|
||||
@@ -277,3 +334,31 @@ int userTotalPoints(Ref ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
return user?.totalPoints ?? 0;
|
||||
}
|
||||
|
||||
/// Initialize Frappe session
|
||||
///
|
||||
/// Call this to ensure a Frappe session exists before making API calls.
|
||||
/// This is separate from the Auth provider to avoid disposal issues.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // On login page or before API calls that need session
|
||||
/// await ref.read(initializeFrappeSessionProvider.future);
|
||||
/// ```
|
||||
@riverpod
|
||||
Future<void> initializeFrappeSession(Ref ref) async {
|
||||
try {
|
||||
final frappeService = await ref.watch(frappeAuthServiceProvider.future);
|
||||
|
||||
// Check if we already have a session
|
||||
final storedSession = await frappeService.getStoredSession();
|
||||
|
||||
if (storedSession == null) {
|
||||
// No session exists, get a public one
|
||||
await frappeService.getSession();
|
||||
}
|
||||
} catch (e) {
|
||||
// Log error but don't throw - allow app to continue
|
||||
print('Failed to initialize Frappe session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,99 @@ final class AuthLocalDataSourceProvider
|
||||
String _$authLocalDataSourceHash() =>
|
||||
r'f104de00a8ab431f6736387fb499c2b6e0ab4924';
|
||||
|
||||
/// Provide FrappeAuthService instance
|
||||
|
||||
@ProviderFor(frappeAuthService)
|
||||
const frappeAuthServiceProvider = FrappeAuthServiceProvider._();
|
||||
|
||||
/// Provide FrappeAuthService instance
|
||||
|
||||
final class FrappeAuthServiceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<FrappeAuthService>,
|
||||
FrappeAuthService,
|
||||
FutureOr<FrappeAuthService>
|
||||
>
|
||||
with
|
||||
$FutureModifier<FrappeAuthService>,
|
||||
$FutureProvider<FrappeAuthService> {
|
||||
/// Provide FrappeAuthService instance
|
||||
const FrappeAuthServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'frappeAuthServiceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$frappeAuthServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<FrappeAuthService> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<FrappeAuthService> create(Ref ref) {
|
||||
return frappeAuthService(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$frappeAuthServiceHash() => r'db239119c9a8510d3439a2d05a7fae1743be11c5';
|
||||
|
||||
/// Provide AuthRemoteDataSource instance
|
||||
|
||||
@ProviderFor(authRemoteDataSource)
|
||||
const authRemoteDataSourceProvider = AuthRemoteDataSourceProvider._();
|
||||
|
||||
/// Provide AuthRemoteDataSource instance
|
||||
|
||||
final class AuthRemoteDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<AuthRemoteDataSource>,
|
||||
AuthRemoteDataSource,
|
||||
FutureOr<AuthRemoteDataSource>
|
||||
>
|
||||
with
|
||||
$FutureModifier<AuthRemoteDataSource>,
|
||||
$FutureProvider<AuthRemoteDataSource> {
|
||||
/// Provide AuthRemoteDataSource instance
|
||||
const AuthRemoteDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authRemoteDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authRemoteDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<AuthRemoteDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<AuthRemoteDataSource> create(Ref ref) {
|
||||
return authRemoteDataSource(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authRemoteDataSourceHash() =>
|
||||
r'3c05cf67fe479a973fc4ce2db68a0abde37974a5';
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
/// Main provider for authentication state management.
|
||||
@@ -179,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
|
||||
Auth create() => Auth();
|
||||
}
|
||||
|
||||
String _$authHash() => r'6f410d1abe6c53a6cbfa52fde7ea7a2d22a7f78d';
|
||||
String _$authHash() => r'3f0562ffb573be47d8aae8beebccb1946240cbb6';
|
||||
|
||||
/// Authentication Provider
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user