login finish

This commit is contained in:
MooN 2026-02-14 16:46:13 +06:30
parent 77e94b5ec0
commit b62d4c56ea
15 changed files with 368 additions and 43 deletions

View File

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

View File

@ -0,0 +1,93 @@
import 'dart:async';
import 'package:http/http.dart' as http;
typedef AccessTokenProvider = String? Function();
typedef RefreshAccessToken = Future<String?> 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<String?>? _ongoingRefresh;
static bool _always(Uri _) => true;
@override
Future<http.StreamedResponse> 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<String?> _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;
}
}

View File

@ -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');
}

View File

@ -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<LoginUser> 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<String, dynamic> _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<String, dynamic> _extractPayload(Map<String, dynamic> data) {
final dynamic nested = data['data'] ?? data['result'] ?? data['payload'];
if (nested is Map<String, dynamic>) {
return nested;
}
return data;
}
String? _pickString(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim();
}
}
return null;
}
}

View File

@ -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<LoginUser> refreshSession({
required String username,
required String refreshToken,
required String role,
}) async {
await Future<void>.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,
);
}
}

View File

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

View File

@ -2,4 +2,10 @@ import 'package:e_receipt_mobile/domain/entities/login_user.dart';
abstract class AuthRepository {
Future<LoginUser> login({required String username, required String password});
Future<LoginUser> refreshSession({
required String username,
required String refreshToken,
required String role,
});
}

View File

@ -0,0 +1,19 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final sessionControllerProvider =
StateNotifierProvider<SessionController, LoginUser?>((ref) {
return SessionController();
});
class SessionController extends StateNotifier<LoginUser?> {
SessionController() : super(null);
void setUser(LoginUser user) {
state = user;
}
void clearUser() {
state = null;
}
}

View File

@ -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<RoundedInput> createState() => _RoundedInputState();
@ -43,9 +47,10 @@ class _RoundedInputState extends State<RoundedInput> {
@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),

View File

@ -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}'),
],
),
),
);
}

View File

@ -43,17 +43,18 @@ class _LoginPageState extends ConsumerState<LoginPage> {
}
if (next.successMessage != null &&
next.user != null &&
previous?.successMessage != next.successMessage) {
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
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<LoginPage> {
),
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

View File

@ -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?,
);
}
}

View File

@ -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<http.Client>((ref) {
final rawHttpClientProvider = Provider<http.Client>((ref) {
final client = LoggingHttpClient();
ref.onDispose(client.close);
return client;
});
final authenticatedHttpClientProvider = Provider<http.Client>((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<AuthRepository>((ref) {
return ApiAuthRepository(
baseUrl: AppConfig.apiBaseUrl,
eReceiptSecret: AppConfig.eReceiptSecret,
client: ref.watch(httpClientProvider),
apiSecret: AppConfig.apiSecret,
client: ref.watch(rawHttpClientProvider),
);
});
final loginViewModelProvider =
StateNotifierProvider<LoginViewModel, LoginState>((ref) {
return LoginViewModel(ref.watch(authRepositoryProvider));
return LoginViewModel(
ref.watch(authRepositoryProvider),
ref.watch(sessionControllerProvider.notifier),
);
});
class LoginViewModel extends StateNotifier<LoginState> {
LoginViewModel(this._authRepository) : super(const LoginState());
LoginViewModel(this._authRepository, this._sessionController)
: super(const LoginState());
final AuthRepository _authRepository;
final SessionController _sessionController;
Future<void> 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(

View File

@ -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:

View File

@ -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: