From b62d4c56ea5c6d448f9af9a317b07f533ab2c4be Mon Sep 17 00:00:00 2001 From: MooN <56061215+MgKyawLay@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:46:13 +0630 Subject: [PATCH] login finish --- lib/core/config/app_config.dart | 2 +- lib/core/network/auth_http_client.dart | 93 +++++++++++++++ lib/core/network/logging_http_client.dart | 2 +- .../repositories/api_auth_repository.dart | 108 +++++++++++++++--- .../repositories/mock_auth_repository.dart | 27 ++++- lib/domain/entities/login_user.dart | 12 +- lib/domain/repositories/auth_repository.dart | 6 + lib/presentation/auth/session_controller.dart | 19 +++ .../components/rounded_input.dart | 7 +- lib/presentation/home/home_screen.dart | 19 ++- lib/presentation/login/login_page.dart | 33 +++++- lib/presentation/login/login_state.dart | 8 ++ lib/presentation/login/login_view_model.dart | 66 +++++++++-- pubspec.lock | 8 -- pubspec.yaml | 1 - 15 files changed, 368 insertions(+), 43 deletions(-) create mode 100644 lib/core/network/auth_http_client.dart create mode 100644 lib/presentation/auth/session_controller.dart diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 57ee2f0..3646f8d 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -2,5 +2,5 @@ class AppConfig { const AppConfig._(); static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443'; - static const String eReceiptSecret = 'y812J21lhha11OS'; + static const String apiSecret = 'y812J21lhha11OS'; } diff --git a/lib/core/network/auth_http_client.dart b/lib/core/network/auth_http_client.dart new file mode 100644 index 0000000..d210c44 --- /dev/null +++ b/lib/core/network/auth_http_client.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; + +typedef AccessTokenProvider = String? Function(); +typedef RefreshAccessToken = Future Function(); +typedef RequestUriPredicate = bool Function(Uri uri); + +class AuthHttpClient extends http.BaseClient { + AuthHttpClient({ + required http.Client innerClient, + required AccessTokenProvider accessTokenProvider, + required RefreshAccessToken refreshAccessToken, + RequestUriPredicate? shouldAttachToken, + RequestUriPredicate? shouldRefreshOnUnauthorized, + }) : _innerClient = innerClient, + _accessTokenProvider = accessTokenProvider, + _refreshAccessToken = refreshAccessToken, + _shouldAttachToken = shouldAttachToken ?? _always, + _shouldRefreshOnUnauthorized = + shouldRefreshOnUnauthorized ?? _always; + + final http.Client _innerClient; + final AccessTokenProvider _accessTokenProvider; + final RefreshAccessToken _refreshAccessToken; + final RequestUriPredicate _shouldAttachToken; + final RequestUriPredicate _shouldRefreshOnUnauthorized; + + Future? _ongoingRefresh; + + static bool _always(Uri _) => true; + + @override + Future send(http.BaseRequest request) async { + if (_shouldAttachToken(request.url)) { + final token = _accessTokenProvider(); + if (token != null && + token.trim().isNotEmpty && + !request.headers.containsKey('Authorization')) { + request.headers['Authorization'] = 'Bearer ${token.trim()}'; + } + } + + final firstResponse = await _innerClient.send(request); + if (firstResponse.statusCode != 401 || + !_shouldRefreshOnUnauthorized(request.url) || + request is! http.Request) { + return firstResponse; + } + + final refreshedToken = await _refreshTokenWithLock(); + if (refreshedToken == null || refreshedToken.trim().isEmpty) { + return firstResponse; + } + + await firstResponse.stream.drain(); + final retryRequest = _cloneRequest(request); + retryRequest.headers['Authorization'] = 'Bearer ${refreshedToken.trim()}'; + return _innerClient.send(retryRequest); + } + + @override + void close() { + _innerClient.close(); + super.close(); + } + + Future _refreshTokenWithLock() async { + final inFlight = _ongoingRefresh; + if (inFlight != null) { + return inFlight; + } + + final nextRefresh = _refreshAccessToken(); + _ongoingRefresh = nextRefresh; + try { + return await nextRefresh; + } finally { + _ongoingRefresh = null; + } + } + + http.Request _cloneRequest(http.Request original) { + final cloned = http.Request(original.method, original.url) + ..followRedirects = original.followRedirects + ..maxRedirects = original.maxRedirects + ..persistentConnection = original.persistentConnection + ..encoding = original.encoding + ..bodyBytes = original.bodyBytes; + cloned.headers.addAll(original.headers); + return cloned; + } +} diff --git a/lib/core/network/logging_http_client.dart b/lib/core/network/logging_http_client.dart index 8c2d9dd..98ee174 100644 --- a/lib/core/network/logging_http_client.dart +++ b/lib/core/network/logging_http_client.dart @@ -48,7 +48,7 @@ class LoggingHttpClient extends http.BaseClient { void _logRequest(http.BaseRequest request, String? requestBody) { final safeHeaders = _sanitizeHeaders(request.headers); debugPrint('[API][REQUEST] ${request.method} ${request.url}'); - debugPrint('[API][REQUEST][HEADERS] ${request.headers}'); + debugPrint('[API][REQUEST][HEADERS] $safeHeaders'); if (requestBody != null && requestBody.isNotEmpty) { debugPrint('[API][REQUEST][BODY] $requestBody'); } diff --git a/lib/data/repositories/api_auth_repository.dart b/lib/data/repositories/api_auth_repository.dart index b8c4948..bf171e2 100644 --- a/lib/data/repositories/api_auth_repository.dart +++ b/lib/data/repositories/api_auth_repository.dart @@ -2,18 +2,17 @@ import 'dart:convert'; import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; -import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; class ApiAuthRepository implements AuthRepository { ApiAuthRepository({ required this.baseUrl, - required this.eReceiptSecret, + required this.apiSecret, http.Client? client, }) : _client = client ?? http.Client(); final String baseUrl; - final String eReceiptSecret; + final String apiSecret; final http.Client _client; @override @@ -26,33 +25,56 @@ class ApiAuthRepository implements AuthRepository { 'username': username.trim().toLowerCase(), 'password': password, }); - final encodedApiKey = _generateApiKey(requestBody); final response = await _client.post( uri, headers: { 'Content-Type': 'application/json', - 'x-api-key': encodedApiKey, + 'x-api-key': apiSecret, }, body: requestBody, ); if (response.statusCode >= 200 && response.statusCode < 300) { - final data = _asMap(response.body); - final apiUsername = (data['username'] ?? data['email'])?.toString(); - return LoginUser( - username: apiUsername?.trim().isNotEmpty == true - ? apiUsername!.trim() - : username.trim(), + return _parseUserFromResponse( + response.body, + fallbackUsername: username.trim(), ); } throw Exception(_extractErrorMessage(response.body)); } - String _generateApiKey(String bodyString) { - final dataToHash = '$bodyString$eReceiptSecret'; - return sha256.convert(utf8.encode(dataToHash)).toString(); + @override + Future refreshSession({ + required String username, + required String refreshToken, + required String role, + }) async { + final uri = Uri.parse('$baseUrl/receipt/auth/refresh-token'); + final requestBody = jsonEncode({ + 'refreshToken': refreshToken, + }); + + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + }, + body: requestBody, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseUserFromResponse( + response.body, + fallbackUsername: username, + fallbackRefreshToken: refreshToken, + fallbackRole: role, + ); + } + + throw Exception(_extractErrorMessage(response.body)); } Map _asMap(String body) { @@ -75,4 +97,62 @@ class ApiAuthRepository implements AuthRepository { } return message.toString(); } + + LoginUser _parseUserFromResponse( + String body, { + required String fallbackUsername, + String? fallbackRefreshToken, + String? fallbackRole, + }) { + final data = _asMap(body); + final payload = _extractPayload(data); + final apiUsername = _pickString(payload, const ['username', 'email']); + final token = _pickString(payload, const ['token', 'accessToken']); + final refreshToken = _pickString( + payload, + const ['refreshToken', 'refresh_token'], + ); + final role = _pickString(payload, const ['role']); + + if (token == null) { + throw Exception('Auth response is missing token'); + } + + final effectiveRefreshToken = refreshToken ?? fallbackRefreshToken; + if (effectiveRefreshToken == null) { + throw Exception('Auth response is missing refresh token'); + } + + final effectiveRole = role ?? fallbackRole; + if (effectiveRole == null || effectiveRole.trim().isEmpty) { + throw Exception('Auth response is missing role'); + } + + return LoginUser( + username: apiUsername?.trim().isNotEmpty == true + ? apiUsername!.trim() + : fallbackUsername.trim(), + token: token, + refreshToken: effectiveRefreshToken, + role: effectiveRole, + ); + } + + Map _extractPayload(Map data) { + final dynamic nested = data['data'] ?? data['result'] ?? data['payload']; + if (nested is Map) { + return nested; + } + return data; + } + + String? _pickString(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString().trim(); + } + } + return null; + } } diff --git a/lib/data/repositories/mock_auth_repository.dart b/lib/data/repositories/mock_auth_repository.dart index 8c8401c..3b31480 100644 --- a/lib/data/repositories/mock_auth_repository.dart +++ b/lib/data/repositories/mock_auth_repository.dart @@ -17,6 +17,31 @@ class MockAuthRepository implements AuthRepository { throw Exception('Password must be at least 6 characters.'); } - return LoginUser(username: username.trim()); + return LoginUser( + username: username.trim(), + token: 'mock-access-token', + refreshToken: 'mock-refresh-token', + role: username.trim().toLowerCase() == 'admin' ? 'admin' : 'user', + ); + } + + @override + Future refreshSession({ + required String username, + required String refreshToken, + required String role, + }) async { + await Future.delayed(const Duration(milliseconds: 300)); + + if (refreshToken.trim().isEmpty) { + throw Exception('Refresh token is required.'); + } + + return LoginUser( + username: username.trim(), + token: 'mock-access-token-refreshed', + refreshToken: 'mock-refresh-token-refreshed', + role: role, + ); } } diff --git a/lib/domain/entities/login_user.dart b/lib/domain/entities/login_user.dart index 821a926..2cf27f3 100644 --- a/lib/domain/entities/login_user.dart +++ b/lib/domain/entities/login_user.dart @@ -1,5 +1,15 @@ class LoginUser { - const LoginUser({required this.username}); + const LoginUser({ + required this.username, + required this.token, + required this.refreshToken, + required this.role, + }); final String username; + final String token; + final String refreshToken; + final String role; + + bool get isAdmin => role.toLowerCase() == 'admin'; } diff --git a/lib/domain/repositories/auth_repository.dart b/lib/domain/repositories/auth_repository.dart index e90e741..a30bd34 100644 --- a/lib/domain/repositories/auth_repository.dart +++ b/lib/domain/repositories/auth_repository.dart @@ -2,4 +2,10 @@ import 'package:e_receipt_mobile/domain/entities/login_user.dart'; abstract class AuthRepository { Future login({required String username, required String password}); + + Future refreshSession({ + required String username, + required String refreshToken, + required String role, + }); } diff --git a/lib/presentation/auth/session_controller.dart b/lib/presentation/auth/session_controller.dart new file mode 100644 index 0000000..dd48627 --- /dev/null +++ b/lib/presentation/auth/session_controller.dart @@ -0,0 +1,19 @@ +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final sessionControllerProvider = + StateNotifierProvider((ref) { + return SessionController(); + }); + +class SessionController extends StateNotifier { + SessionController() : super(null); + + void setUser(LoginUser user) { + state = user; + } + + void clearUser() { + state = null; + } +} diff --git a/lib/presentation/components/rounded_input.dart b/lib/presentation/components/rounded_input.dart index 98ef137..d8863e7 100644 --- a/lib/presentation/components/rounded_input.dart +++ b/lib/presentation/components/rounded_input.dart @@ -5,12 +5,14 @@ Widget roundedInput({ required String hint, required IconData icon, bool isPassword = false, + String? Function(String?)? validator, }) { return RoundedInput( controller: controller, hint: hint, icon: icon, isPassword: isPassword, + validator: validator, ); } @@ -21,12 +23,14 @@ class RoundedInput extends StatefulWidget { required this.hint, required this.icon, this.isPassword = false, + this.validator, }); final TextEditingController controller; final String hint; final IconData icon; final bool isPassword; + final String? Function(String?)? validator; @override State createState() => _RoundedInputState(); @@ -43,9 +47,10 @@ class _RoundedInputState extends State { @override Widget build(BuildContext context) { - return TextField( + return TextFormField( controller: widget.controller, obscureText: _obscured, + validator: widget.validator, decoration: InputDecoration( hintText: widget.hint, prefixIcon: Icon(widget.icon, color: Colors.grey), diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index adb3d58..bc60389 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -1,17 +1,28 @@ +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:flutter/material.dart'; class HomeScreen extends StatelessWidget { - const HomeScreen({super.key}); + const HomeScreen({required this.user, super.key}); + + final LoginUser user; @override Widget build(BuildContext context) { + final role = user.role.toLowerCase(); return Scaffold( appBar: AppBar( - title: const Text('Home'), + title: Text(role == 'admin' ? 'Admin Home' : 'User Home'), centerTitle: true, ), - body: const Center( - child: Text('Welcome to Home Screen'), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Welcome ${user.username}'), + const SizedBox(height: 8), + Text('Role: ${user.role}'), + ], + ), ), ); } diff --git a/lib/presentation/login/login_page.dart b/lib/presentation/login/login_page.dart index f6424ff..e44653b 100644 --- a/lib/presentation/login/login_page.dart +++ b/lib/presentation/login/login_page.dart @@ -43,17 +43,18 @@ class _LoginPageState extends ConsumerState { } if (next.successMessage != null && + next.user != null && previous?.successMessage != next.successMessage) { Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (_) => const HomeScreen(), + builder: (_) => HomeScreen(user: next.user!), ), ); } }); return Scaffold( - backgroundColor: Colors.blue, + backgroundColor: Colors.white, body: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(32), @@ -79,10 +80,34 @@ class _LoginPageState extends ConsumerState { ), const SizedBox(height: 20), //Username - roundedInput(controller: _usernameController, hint: "Username", icon: Icons.person), + roundedInput( + controller: _usernameController, + hint: "Username", + icon: Icons.person, + validator: (value) { + if ((value ?? '').trim().isEmpty) { + return 'Username is required'; + } + return null; + }, + ), const SizedBox(height: 12), //Password - roundedInput(controller: _passwordController, hint: "Password", icon: Icons.lock, isPassword: true), + roundedInput( + controller: _passwordController, + hint: "Password", + icon: Icons.lock, + isPassword: true, + validator: (value) { + if ((value ?? '').isEmpty) { + return 'Password is required'; + } + if (value!.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), const SizedBox(height: 20), FilledButton( onPressed: state.isLoading diff --git a/lib/presentation/login/login_state.dart b/lib/presentation/login/login_state.dart index 0611423..75924e0 100644 --- a/lib/presentation/login/login_state.dart +++ b/lib/presentation/login/login_state.dart @@ -1,18 +1,25 @@ +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; + class LoginState { const LoginState({ this.isLoading = false, this.errorMessage, this.successMessage, + this.user, }); final bool isLoading; final String? errorMessage; final String? successMessage; + final LoginUser? user; + + static const Object _unset = Object(); LoginState copyWith({ bool? isLoading, String? errorMessage, String? successMessage, + Object? user = _unset, bool clearError = false, bool clearSuccess = false, }) { @@ -22,6 +29,7 @@ class LoginState { successMessage: clearSuccess ? null : successMessage ?? this.successMessage, + user: identical(user, _unset) ? this.user : user as LoginUser?, ); } } diff --git a/lib/presentation/login/login_view_model.dart b/lib/presentation/login/login_view_model.dart index c3208f1..f3049b5 100644 --- a/lib/presentation/login/login_view_model.dart +++ b/lib/presentation/login/login_view_model.dart @@ -1,50 +1,102 @@ import 'package:e_receipt_mobile/core/config/app_config.dart'; +import 'package:e_receipt_mobile/core/network/auth_http_client.dart'; import 'package:e_receipt_mobile/core/network/logging_http_client.dart'; import 'package:e_receipt_mobile/data/repositories/api_auth_repository.dart'; import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/login/login_state.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; -final httpClientProvider = Provider((ref) { +final rawHttpClientProvider = Provider((ref) { final client = LoggingHttpClient(); ref.onDispose(client.close); return client; }); +final authenticatedHttpClientProvider = Provider((ref) { + final client = AuthHttpClient( + innerClient: LoggingHttpClient(), + accessTokenProvider: () => ref.read(sessionControllerProvider)?.token, + shouldRefreshOnUnauthorized: (uri) => + !uri.path.endsWith('/receipt/auth/login') && + !uri.path.endsWith('/receipt/auth/refresh-token'), + refreshAccessToken: () async { + final sessionUser = ref.read(sessionControllerProvider); + if (sessionUser == null) { + return null; + } + + try { + final refreshedUser = await ref.read(authRepositoryProvider).refreshSession( + username: sessionUser.username, + refreshToken: sessionUser.refreshToken, + role: sessionUser.role, + ); + ref.read(sessionControllerProvider.notifier).setUser(refreshedUser); + return refreshedUser.token; + } catch (_) { + ref.read(sessionControllerProvider.notifier).clearUser(); + return null; + } + }, + ); + ref.onDispose(client.close); + return client; +}); + final authRepositoryProvider = Provider((ref) { return ApiAuthRepository( baseUrl: AppConfig.apiBaseUrl, - eReceiptSecret: AppConfig.eReceiptSecret, - client: ref.watch(httpClientProvider), + apiSecret: AppConfig.apiSecret, + client: ref.watch(rawHttpClientProvider), ); }); final loginViewModelProvider = StateNotifierProvider((ref) { - return LoginViewModel(ref.watch(authRepositoryProvider)); + return LoginViewModel( + ref.watch(authRepositoryProvider), + ref.watch(sessionControllerProvider.notifier), + ); }); class LoginViewModel extends StateNotifier { - LoginViewModel(this._authRepository) : super(const LoginState()); + LoginViewModel(this._authRepository, this._sessionController) + : super(const LoginState()); final AuthRepository _authRepository; + final SessionController _sessionController; Future login({required String username, required String password}) async { + final trimmedUsername = username.trim(); + if (trimmedUsername.isEmpty || password.isEmpty) { + state = state.copyWith( + isLoading: false, + errorMessage: 'Username and password are required', + clearSuccess: true, + ); + return; + } + state = state.copyWith( isLoading: true, clearError: true, clearSuccess: true, + user: null, ); + _sessionController.clearUser(); try { final user = await _authRepository.login( - username: username, + username: trimmedUsername, password: password, ); + _sessionController.setUser(user); state = state.copyWith( isLoading: false, - successMessage: 'Welcome ${user.username}', + user: user, + successMessage: 'Welcome ${user.username} (${user.role})', ); } catch (error) { state = state.copyWith( diff --git a/pubspec.lock b/pubspec.lock index f1c30ce..c7c2c7c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - crypto: - dependency: "direct main" - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" cupertino_icons: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 64b2e7c..a07ba84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,6 @@ dependencies: cupertino_icons: ^1.0.8 flutter_riverpod: ^2.6.1 http: ^1.2.2 - crypto: ^3.0.6 dev_dependencies: flutter_test: