diff --git a/lib/data/repositories/api_auth_repository.dart b/lib/data/repositories/api_auth_repository.dart index 49806cd..1239a52 100644 --- a/lib/data/repositories/api_auth_repository.dart +++ b/lib/data/repositories/api_auth_repository.dart @@ -33,7 +33,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200) { + if (response.statusCode >= 200 && response.statusCode < 300) { return _parseUserFromResponse( response.body, fallbackUsername: username.trim(), @@ -73,7 +73,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200) { + if (response.statusCode >= 200 && response.statusCode < 300) { return _parseUserFromResponse( response.body, fallbackUsername: username, @@ -89,9 +89,14 @@ class ApiAuthRepository implements AuthRepository { Future resetPassword({ required String userId, required String password, + required String oldPassword, }) async { final uri = Uri.parse('$baseUrl/auth/reset-password'); - final requestBody = jsonEncode({'userId': userId, 'password': password}); + final requestBody = jsonEncode({ + 'userId': userId, + 'password': password, + 'password2': oldPassword, + }); final response = await _client.put( uri, @@ -99,7 +104,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200) { + if (response.statusCode >= 200 && response.statusCode < 300) { return; } @@ -119,7 +124,7 @@ class ApiAuthRepository implements AuthRepository { headers: {'Content-Type': 'application/json', 'x-api-key': apiSecret}, ); - if (response.statusCode >= 200) { + if (response.statusCode >= 200 && response.statusCode < 300) { return _extractSuccessMessage( response.body, fallback: 'Password reset successfully', @@ -148,7 +153,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200) { + if (response.statusCode >= 200 && response.statusCode < 300) { return _extractSuccessMessage( response.body, fallback: 'Password changed successfully', @@ -169,7 +174,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200) { + if (response.statusCode >= 200 && response.statusCode < 300) { return; } diff --git a/lib/data/repositories/mock_auth_repository.dart b/lib/data/repositories/mock_auth_repository.dart index ba97db1..a8899bd 100644 --- a/lib/data/repositories/mock_auth_repository.dart +++ b/lib/data/repositories/mock_auth_repository.dart @@ -49,12 +49,16 @@ class MockAuthRepository implements AuthRepository { Future resetPassword({ required String userId, required String password, + required String oldPassword, }) async { await Future.delayed(const Duration(milliseconds: 500)); if (userId.trim().isEmpty) { throw Exception('User ID is required.'); } + if (oldPassword.isEmpty) { + throw Exception('Old password is required.'); + } if (password.length < 8) { throw Exception('Password must be at least 8 characters.'); } diff --git a/lib/domain/repositories/auth_repository.dart b/lib/domain/repositories/auth_repository.dart index 637a587..f5c6684 100644 --- a/lib/domain/repositories/auth_repository.dart +++ b/lib/domain/repositories/auth_repository.dart @@ -12,6 +12,7 @@ abstract class AuthRepository { Future resetPassword({ required String userId, required String password, + required String oldPassword, }); Future resetCashierPassword({required String username}); diff --git a/lib/presentation/login/login_page.dart b/lib/presentation/login/login_page.dart index 3f17efb..4192d76 100644 --- a/lib/presentation/login/login_page.dart +++ b/lib/presentation/login/login_page.dart @@ -31,7 +31,9 @@ class LoginPage extends ConsumerWidget { } TextInput.finishAutofillContext(); - ref.read(loginViewModelProvider.notifier).login( + ref + .read(loginViewModelProvider.notifier) + .login( username: usernameController.text, password: passwordController.text, ); @@ -51,6 +53,7 @@ class LoginPage extends ConsumerWidget { switch (action) { case LoginRequiredAction.passwordUpdateRequired: final userId = (next.requiredUserId ?? '').trim(); + final oldPassword = next.requiredOldPassword ?? ''; if (userId.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -61,6 +64,18 @@ class LoginPage extends ConsumerWidget { ref.read(loginViewModelProvider.notifier).clearRequirement(); return; } + if (oldPassword.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Password update required (missing login password)', + ), + behavior: SnackBarBehavior.floating, + ), + ); + ref.read(loginViewModelProvider.notifier).clearRequirement(); + return; + } ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -70,7 +85,10 @@ class LoginPage extends ConsumerWidget { ); ref.read(loginViewModelProvider.notifier).clearRequirement(); Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ResetPasswordPage(userId: userId)), + MaterialPageRoute( + builder: (_) => + ResetPasswordPage(userId: userId, oldPassword: oldPassword), + ), ); } }); @@ -199,11 +217,11 @@ class LoginPage extends ConsumerWidget { filled: true, fillColor: colorScheme.surfaceContainerHighest .withOpacity( - Theme.of(context).brightness == - Brightness.dark - ? 0.55 - : 0.9, - ), + Theme.of(context).brightness == + Brightness.dark + ? 0.55 + : 0.9, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(18), ), @@ -240,11 +258,11 @@ class LoginPage extends ConsumerWidget { filled: true, fillColor: colorScheme.surfaceContainerHighest .withOpacity( - Theme.of(context).brightness == - Brightness.dark - ? 0.55 - : 0.9, - ), + Theme.of(context).brightness == + Brightness.dark + ? 0.55 + : 0.9, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(18), ), @@ -254,13 +272,14 @@ class LoginPage extends ConsumerWidget { : 'Hide password', onPressed: state.isLoading ? null - : () => ref - .read( - loginObscurePasswordProvider - .notifier, - ) - .state = - !obscurePassword, + : () => + ref + .read( + loginObscurePasswordProvider + .notifier, + ) + .state = + !obscurePassword, icon: Icon( obscurePassword ? Icons.visibility_outlined diff --git a/lib/presentation/login/login_state.dart b/lib/presentation/login/login_state.dart index 3d44890..a41c4ba 100644 --- a/lib/presentation/login/login_state.dart +++ b/lib/presentation/login/login_state.dart @@ -12,6 +12,7 @@ class LoginState { this.user, this.requiredAction, this.requiredUserId, + this.requiredOldPassword, }); final bool isLoading; @@ -20,6 +21,7 @@ class LoginState { final LoginUser? user; final LoginRequiredAction? requiredAction; final String? requiredUserId; + final String? requiredOldPassword; LoginState copyWith({ bool? isLoading, @@ -28,6 +30,7 @@ class LoginState { LoginUser? user, LoginRequiredAction? requiredAction, String? requiredUserId, + String? requiredOldPassword, bool clearError = false, bool clearSuccess = false, bool clearUser = false, @@ -40,13 +43,20 @@ class LoginState { ? null : successMessage ?? this.successMessage, user: clearUser ? null : user ?? this.user, - requiredAction: clearRequired ? null : requiredAction ?? this.requiredAction, - requiredUserId: clearRequired ? null : requiredUserId ?? this.requiredUserId, + requiredAction: clearRequired + ? null + : requiredAction ?? this.requiredAction, + requiredUserId: clearRequired + ? null + : requiredUserId ?? this.requiredUserId, + requiredOldPassword: clearRequired + ? null + : requiredOldPassword ?? this.requiredOldPassword, ); } @override String toString() { - return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user, requiredAction: $requiredAction, requiredUserId: $requiredUserId)'; + return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user, requiredAction: $requiredAction, requiredUserId: $requiredUserId, requiredOldPassword: $requiredOldPassword)'; } } diff --git a/lib/presentation/login/login_view_model.dart b/lib/presentation/login/login_view_model.dart index d9b5971..514e987 100644 --- a/lib/presentation/login/login_view_model.dart +++ b/lib/presentation/login/login_view_model.dart @@ -129,6 +129,7 @@ class LoginViewModel extends StateNotifier { isLoading: false, requiredAction: LoginRequiredAction.passwordUpdateRequired, requiredUserId: error.userId, + requiredOldPassword: password, clearError: true, clearSuccess: true, ); diff --git a/lib/presentation/reset_password/reset_password_page.dart b/lib/presentation/reset_password/reset_password_page.dart index 0c402bd..e9222a5 100644 --- a/lib/presentation/reset_password/reset_password_page.dart +++ b/lib/presentation/reset_password/reset_password_page.dart @@ -6,9 +6,14 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class ResetPasswordPage extends ConsumerWidget { - const ResetPasswordPage({required this.userId, super.key}); + const ResetPasswordPage({ + required this.userId, + required this.oldPassword, + super.key, + }); final String userId; + final String oldPassword; bool _isStrongPassword(String value) { return value.length >= 8 && @@ -52,7 +57,11 @@ class ResetPasswordPage extends ConsumerWidget { ref .read(resetPasswordViewModelProvider.notifier) - .resetPassword(userId: userId, password: password); + .resetPassword( + userId: userId, + password: password, + oldPassword: oldPassword, + ); } @override @@ -64,19 +73,13 @@ class ResetPasswordPage extends ConsumerWidget { final message = next.errorMessage; if (message != null && message != previous?.errorMessage) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - behavior: SnackBarBehavior.floating, - ), + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), ); } final success = next.successMessage; if (success != null && success != previous?.successMessage) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(success), - behavior: SnackBarBehavior.floating, - ), + SnackBar(content: Text(success), behavior: SnackBarBehavior.floating), ); Navigator.of(context).pop(); } @@ -101,14 +104,18 @@ class ResetPasswordPage extends ConsumerWidget { child: SafeArea( child: Center( child: SingleChildScrollView( - padding: - const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, + ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), child: Card( elevation: 0, color: colorScheme.surface.withOpacity( - Theme.of(context).brightness == Brightness.dark ? 0.75 : 0.92, + Theme.of(context).brightness == Brightness.dark + ? 0.75 + : 0.92, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(28), @@ -159,7 +166,9 @@ class ResetPasswordPage extends ConsumerWidget { style: Theme.of(context) .textTheme .titleLarge - ?.copyWith(fontWeight: FontWeight.w800), + ?.copyWith( + fontWeight: FontWeight.w800, + ), ), Text( 'Your account requires a password change.', @@ -167,7 +176,8 @@ class ResetPasswordPage extends ConsumerWidget { .textTheme .bodyMedium ?.copyWith( - color: colorScheme.onSurfaceVariant, + color: + colorScheme.onSurfaceVariant, ), ), ], @@ -182,8 +192,9 @@ class ResetPasswordPage extends ConsumerWidget { enabled: !state.isLoading, textInputAction: TextInputAction.next, obscureText: obscure, - onFieldSubmitted: (_) => - FocusScope.of(context).requestFocus(confirmFocusNode), + onFieldSubmitted: (_) => FocusScope.of( + context, + ).requestFocus(confirmFocusNode), validator: (value) { final text = (value ?? '').trim(); if (text.isEmpty) { @@ -201,24 +212,28 @@ class ResetPasswordPage extends ConsumerWidget { filled: true, fillColor: colorScheme.surfaceContainerHighest .withOpacity( - Theme.of(context).brightness == Brightness.dark - ? 0.55 - : 0.9, - ), + Theme.of(context).brightness == + Brightness.dark + ? 0.55 + : 0.9, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(18), ), suffixIcon: IconButton( - tooltip: - obscure ? 'Show password' : 'Hide password', + tooltip: obscure + ? 'Show password' + : 'Hide password', onPressed: state.isLoading ? null - : () => ref - .read( - resetObscurePasswordProvider.notifier, - ) - .state = - !obscure, + : () => + ref + .read( + resetObscurePasswordProvider + .notifier, + ) + .state = + !obscure, icon: Icon( obscure ? Icons.visibility_outlined @@ -255,10 +270,11 @@ class ResetPasswordPage extends ConsumerWidget { filled: true, fillColor: colorScheme.surfaceContainerHighest .withOpacity( - Theme.of(context).brightness == Brightness.dark - ? 0.55 - : 0.9, - ), + Theme.of(context).brightness == + Brightness.dark + ? 0.55 + : 0.9, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(18), ), @@ -268,13 +284,14 @@ class ResetPasswordPage extends ConsumerWidget { : 'Hide password', onPressed: state.isLoading ? null - : () => ref - .read( - resetObscureConfirmPasswordProvider - .notifier, - ) - .state = - !obscureConfirm, + : () => + ref + .read( + resetObscureConfirmPasswordProvider + .notifier, + ) + .state = + !obscureConfirm, icon: Icon( obscureConfirm ? Icons.visibility_outlined @@ -288,25 +305,33 @@ class ResetPasswordPage extends ConsumerWidget { onPressed: state.isLoading ? null : () => _submit( - context: context, - ref: ref, - formKey: formKey, - passwordController: passwordController, - confirmController: confirmController, - isLoading: state.isLoading, - ), + context: context, + ref: ref, + formKey: formKey, + passwordController: passwordController, + confirmController: confirmController, + isLoading: state.isLoading, + ), icon: state.isLoading ? const SizedBox( height: 18, width: 18, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + ), ) : const Icon(Icons.check), label: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric( + vertical: 12, + ), child: Text( - state.isLoading ? 'Updating...' : 'Reset password', - style: const TextStyle(fontWeight: FontWeight.w600), + state.isLoading + ? 'Updating...' + : 'Reset password', + style: const TextStyle( + fontWeight: FontWeight.w600, + ), ), ), ), @@ -314,7 +339,8 @@ class ResetPasswordPage extends ConsumerWidget { Text( 'Tip: use a unique password you don’t use elsewhere.', textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( color: colorScheme.onSurfaceVariant, ), ), diff --git a/lib/presentation/reset_password/reset_password_view_model.dart b/lib/presentation/reset_password/reset_password_view_model.dart index ac8db86..23807e9 100644 --- a/lib/presentation/reset_password/reset_password_view_model.dart +++ b/lib/presentation/reset_password/reset_password_view_model.dart @@ -4,20 +4,23 @@ import 'package:e_receipt_mobile/presentation/reset_password/reset_password_stat import 'package:flutter_riverpod/flutter_riverpod.dart'; final resetPasswordViewModelProvider = - StateNotifierProvider.autoDispose( - (ref) { - return ResetPasswordViewModel(ref.watch(authRepositoryProvider)); - }, -); + StateNotifierProvider.autoDispose< + ResetPasswordViewModel, + ResetPasswordState + >((ref) { + return ResetPasswordViewModel(ref.watch(authRepositoryProvider)); + }); class ResetPasswordViewModel extends StateNotifier { - ResetPasswordViewModel(this._authRepository) : super(const ResetPasswordState()); + ResetPasswordViewModel(this._authRepository) + : super(const ResetPasswordState()); final AuthRepository _authRepository; Future resetPassword({ required String userId, required String password, + required String oldPassword, }) async { if (userId.trim().isEmpty) { state = state.copyWith( @@ -27,6 +30,14 @@ class ResetPasswordViewModel extends StateNotifier { ); return; } + if (oldPassword.isEmpty) { + state = state.copyWith( + isLoading: false, + errorMessage: 'Missing original login password', + clearSuccess: true, + ); + return; + } state = state.copyWith( isLoading: true, @@ -35,7 +46,11 @@ class ResetPasswordViewModel extends StateNotifier { ); try { - await _authRepository.resetPassword(userId: userId.trim(), password: password); + await _authRepository.resetPassword( + userId: userId.trim(), + password: password, + oldPassword: oldPassword, + ); state = state.copyWith( isLoading: false, successMessage: 'Password updated',