From b3494d71dd75e9481cc1dff39b0f07032cab99b1 Mon Sep 17 00:00:00 2001 From: moon <56061215+MgKyawLay@users.noreply.github.com> Date: Mon, 11 May 2026 21:05:11 +0630 Subject: [PATCH] reset-cashier-password --- lib/core/config/app_config.dart | 4 +- .../repositories/api_auth_repository.dart | 81 ++-- .../repositories/mock_auth_repository.dart | 11 + lib/domain/repositories/auth_repository.dart | 7 +- .../admin_reset_password_form_providers.dart | 26 ++ .../admin_reset_password_page.dart | 392 ++++++++++++++++ .../admin_reset_password_state.dart | 30 ++ .../admin_reset_password_view_model.dart | 56 +++ lib/presentation/home/home_screen.dart | 437 +++++++++--------- .../home/widgets/home_drawer.dart | 45 +- lib/presentation/login/login_view_model.dart | 8 + user.md | 102 ++++ 12 files changed, 933 insertions(+), 266 deletions(-) create mode 100644 lib/presentation/admin_reset_password/admin_reset_password_form_providers.dart create mode 100644 lib/presentation/admin_reset_password/admin_reset_password_page.dart create mode 100644 lib/presentation/admin_reset_password/admin_reset_password_state.dart create mode 100644 lib/presentation/admin_reset_password/admin_reset_password_view_model.dart create mode 100644 user.md diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 485caf2..2be89ba 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -1,7 +1,7 @@ class AppConfig { const AppConfig._(); - static const String apiBaseUrl = 'https://api-tms.kbzbank.com/receipt'; - // static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com'; + // static const String apiBaseUrl = 'https://api-tms.kbzbank.com/receipt'; + static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com'; static const String apiSecret = 'y812J21lhha11OS'; } diff --git a/lib/data/repositories/api_auth_repository.dart b/lib/data/repositories/api_auth_repository.dart index fef17fa..a7446cc 100644 --- a/lib/data/repositories/api_auth_repository.dart +++ b/lib/data/repositories/api_auth_repository.dart @@ -29,10 +29,7 @@ class ApiAuthRepository implements AuthRepository { final response = await _client.post( uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - }, + headers: {'Content-Type': 'application/json', 'x-api-key': apiSecret}, body: requestBody, ); @@ -68,16 +65,11 @@ class ApiAuthRepository implements AuthRepository { required String role, }) async { final uri = Uri.parse('$baseUrl/auth/refresh-token'); - final requestBody = jsonEncode({ - 'refreshToken': refreshToken, - }); + final requestBody = jsonEncode({'refreshToken': refreshToken}); final response = await _client.post( uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - }, + headers: {'Content-Type': 'application/json', 'x-api-key': apiSecret}, body: requestBody, ); @@ -99,17 +91,11 @@ class ApiAuthRepository implements AuthRepository { required String password, }) async { final uri = Uri.parse('$baseUrl/auth/reset-password'); - final requestBody = jsonEncode({ - 'userId': userId, - 'password': password, - }); + final requestBody = jsonEncode({'userId': userId, 'password': password}); final response = await _client.put( uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - }, + headers: {'Content-Type': 'application/json', 'x-api-key': apiSecret}, body: requestBody, ); @@ -121,18 +107,36 @@ class ApiAuthRepository implements AuthRepository { } @override - Future logout({required String refreshToken}) async { - final uri = Uri.parse('$baseUrl/auth/logout'); - final requestBody = jsonEncode({ - 'refreshToken': refreshToken, - }); + Future resetCashierPassword({required String username}) async { + final trimmedUsername = username.trim(); + final encodedUsername = Uri.encodeComponent(trimmedUsername); + final uri = Uri.parse( + '$baseUrl/user/$encodedUsername/cashier-reset-password', + ); final response = await _client.post( uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - }, + headers: {'Content-Type': 'application/json', 'x-api-key': apiSecret}, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _extractSuccessMessage( + response.body, + fallback: 'Password reset successfully', + ); + } + + throw Exception(_extractErrorMessage(response.body)); + } + + @override + Future logout({required String refreshToken}) async { + final uri = Uri.parse('$baseUrl/auth/logout'); + final requestBody = jsonEncode({'refreshToken': refreshToken}); + + final response = await _client.post( + uri, + headers: {'Content-Type': 'application/json', 'x-api-key': apiSecret}, body: requestBody, ); @@ -164,6 +168,19 @@ class ApiAuthRepository implements AuthRepository { return message.toString(); } + String _extractSuccessMessage(String body, {required String fallback}) { + final data = _asMap(body); + final message = + data['message'] ?? + data['success'] ?? + data['detail'] ?? + _extractPayload(data)['message']; + if (message == null || message.toString().trim().isEmpty) { + return fallback; + } + return message.toString().trim(); + } + String? _extractUserId(Map data) { final rootUser = data['user']; if (rootUser is Map) { @@ -192,10 +209,10 @@ class ApiAuthRepository implements AuthRepository { 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 refreshToken = _pickString(payload, const [ + 'refreshToken', + 'refresh_token', + ]); final role = _pickString(payload, const ['role']); if (token == null) { diff --git a/lib/data/repositories/mock_auth_repository.dart b/lib/data/repositories/mock_auth_repository.dart index 045f135..f649e3f 100644 --- a/lib/data/repositories/mock_auth_repository.dart +++ b/lib/data/repositories/mock_auth_repository.dart @@ -60,6 +60,17 @@ class MockAuthRepository implements AuthRepository { } } + @override + Future resetCashierPassword({required String username}) async { + await Future.delayed(const Duration(milliseconds: 500)); + + if (username.trim().isEmpty) { + throw Exception('Username is required.'); + } + + return 'Password reset successfully'; + } + @override Future logout({required String refreshToken}) async { await Future.delayed(const Duration(milliseconds: 250)); diff --git a/lib/domain/repositories/auth_repository.dart b/lib/domain/repositories/auth_repository.dart index 5c0a4b6..f80424c 100644 --- a/lib/domain/repositories/auth_repository.dart +++ b/lib/domain/repositories/auth_repository.dart @@ -9,7 +9,12 @@ abstract class AuthRepository { required String role, }); - Future resetPassword({required String userId, required String password}); + Future resetPassword({ + required String userId, + required String password, + }); + + Future resetCashierPassword({required String username}); Future logout({required String refreshToken}); } diff --git a/lib/presentation/admin_reset_password/admin_reset_password_form_providers.dart b/lib/presentation/admin_reset_password/admin_reset_password_form_providers.dart new file mode 100644 index 0000000..51a2689 --- /dev/null +++ b/lib/presentation/admin_reset_password/admin_reset_password_form_providers.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final adminResetPasswordFormKeyProvider = + Provider.autoDispose>((ref) { + return GlobalKey(); + }); + +final adminResetUsernameControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; + }); + +final adminResetUsernameFocusNodeProvider = Provider.autoDispose(( + ref, +) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final adminResetAttemptedSubmitProvider = StateProvider.autoDispose( + (ref) => false, +); diff --git a/lib/presentation/admin_reset_password/admin_reset_password_page.dart b/lib/presentation/admin_reset_password/admin_reset_password_page.dart new file mode 100644 index 0000000..b3bbdf9 --- /dev/null +++ b/lib/presentation/admin_reset_password/admin_reset_password_page.dart @@ -0,0 +1,392 @@ +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:e_receipt_mobile/presentation/admin_reset_password/admin_reset_password_form_providers.dart'; +import 'package:e_receipt_mobile/presentation/admin_reset_password/admin_reset_password_view_model.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class AdminResetPasswordPage extends ConsumerWidget { + const AdminResetPasswordPage({super.key}); + + String? _buildDefaultPassword(String username) { + final trimmed = username.trim(); + if (trimmed.length < 4) { + return null; + } + + final firstThree = trimmed.substring(0, 3).toUpperCase(); + final fourth = trimmed.substring(3, 4).toLowerCase(); + final lastFour = trimmed.substring(trimmed.length - 4); + return '$firstThree$fourth@$lastFour'; + } + + void _submit({ + required WidgetRef ref, + required GlobalKey formKey, + required TextEditingController usernameController, + required bool isLoading, + }) { + if (isLoading) { + return; + } + + ref.read(adminResetPasswordViewModelProvider.notifier).clearMessages(); + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(adminResetAttemptedSubmitProvider.notifier).state = true; + + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + ref + .read(adminResetPasswordViewModelProvider.notifier) + .resetCashierPassword(username: usernameController.text); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(sessionControllerProvider); + if (!(user?.isAdmin ?? false)) { + return _AdminResetPasswordForbiddenPage(user: user); + } + + final state = ref.watch(adminResetPasswordViewModelProvider); + final colorScheme = Theme.of(context).colorScheme; + + ref.listen(adminResetPasswordViewModelProvider, (previous, next) { + final message = next.errorMessage; + if (message != null && message != previous?.errorMessage) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + final success = next.successMessage; + if (success != null && success != previous?.successMessage) { + final username = ref + .read(adminResetUsernameControllerProvider) + .text + .trim(); + final generatedPassword = _buildDefaultPassword(username); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Password reset'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'The cashier password has been reset to the default temporary password.', + ), + if (generatedPassword != null) ...[ + const SizedBox(height: 12), + SelectableText( + generatedPassword, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'The cashier will be required to change it on next login.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () { + final navigator = Navigator.of(context); + navigator.pop(); + Navigator.of( + context, + rootNavigator: true, + ).popUntil((route) => route.isFirst); + }, + child: const Text('Back to home'), + ), + ], + ); + }, + ); + } + }); + + final formKey = ref.watch(adminResetPasswordFormKeyProvider); + final usernameController = ref.watch(adminResetUsernameControllerProvider); + final usernameFocusNode = ref.watch(adminResetUsernameFocusNodeProvider); + final attemptedSubmit = ref.watch(adminResetAttemptedSubmitProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Reset cashier password')), + body: AnnotatedRegion( + value: Theme.of(context).brightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + child: LoginBackground( + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Card( + elevation: 0, + color: colorScheme.surface.withValues( + alpha: Theme.of(context).brightness == Brightness.dark + ? 0.75 + : 0.92, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + side: BorderSide( + color: colorScheme.outlineVariant.withValues( + alpha: 0.35, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + child: Form( + key: formKey, + autovalidateMode: attemptedSubmit + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primary, + colorScheme.tertiary, + ], + ), + ), + child: const Icon( + Icons.admin_panel_settings_outlined, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Reset cashier access', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + 'Reset a cashier account to its temporary default password.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: + colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + TextFormField( + controller: usernameController, + focusNode: usernameFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.done, + textCapitalization: TextCapitalization.characters, + onFieldSubmitted: (_) => _submit( + ref: ref, + formKey: formKey, + usernameController: usernameController, + isLoading: state.isLoading, + ), + validator: (value) { + final text = (value ?? '').trim(); + if (text.isEmpty) { + return 'Cashier username is required'; + } + if (text.length < 4) { + return 'Username must be at least 4 characters'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Cashier username', + hintText: 'Enter terminal username', + prefixIcon: const Icon(Icons.badge_outlined), + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withValues( + alpha: + Theme.of(context).brightness == + Brightness.dark + ? 0.55 + : 0.9, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + ), + const SizedBox(height: 14), + ValueListenableBuilder( + valueListenable: usernameController, + builder: (context, value, _) { + final generatedPassword = _buildDefaultPassword( + value.text, + ); + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh + .withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: colorScheme.outlineVariant + .withValues(alpha: 0.45), + ), + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Default temporary password', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + SelectableText( + generatedPassword ?? + 'Enter at least 4 username characters to preview the generated password.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: generatedPassword == null + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + fontWeight: + generatedPassword == null + ? FontWeight.w400 + : FontWeight.w700, + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: state.isLoading + ? null + : () => _submit( + ref: ref, + formKey: formKey, + usernameController: usernameController, + isLoading: state.isLoading, + ), + icon: state.isLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.lock_reset_outlined), + label: Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + child: Text( + state.isLoading + ? 'Resetting...' + : 'Reset password', + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 10), + Text( + 'This will mark the password as temporary and force a password change on next login.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _AdminResetPasswordForbiddenPage extends StatelessWidget { + const _AdminResetPasswordForbiddenPage({required this.user}); + + final LoginUser? user; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Reset cashier password')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + user == null + ? 'You must be logged in as admin to access this page.' + : 'Only admin users can reset cashier passwords.', + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/admin_reset_password/admin_reset_password_state.dart b/lib/presentation/admin_reset_password/admin_reset_password_state.dart new file mode 100644 index 0000000..b1df079 --- /dev/null +++ b/lib/presentation/admin_reset_password/admin_reset_password_state.dart @@ -0,0 +1,30 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class AdminResetPasswordState { + const AdminResetPasswordState({ + this.isLoading = false, + this.errorMessage, + this.successMessage, + }); + + final bool isLoading; + final String? errorMessage; + final String? successMessage; + + AdminResetPasswordState copyWith({ + bool? isLoading, + String? errorMessage, + String? successMessage, + bool clearError = false, + bool clearSuccess = false, + }) { + return AdminResetPasswordState( + isLoading: isLoading ?? this.isLoading, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + successMessage: clearSuccess + ? null + : successMessage ?? this.successMessage, + ); + } +} diff --git a/lib/presentation/admin_reset_password/admin_reset_password_view_model.dart b/lib/presentation/admin_reset_password/admin_reset_password_view_model.dart new file mode 100644 index 0000000..926aae5 --- /dev/null +++ b/lib/presentation/admin_reset_password/admin_reset_password_view_model.dart @@ -0,0 +1,56 @@ +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/presentation/admin_reset_password/admin_reset_password_state.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final adminResetPasswordViewModelProvider = + StateNotifierProvider.autoDispose< + AdminResetPasswordViewModel, + AdminResetPasswordState + >((ref) { + return AdminResetPasswordViewModel( + ref.watch(authenticatedAuthRepositoryProvider), + ); + }); + +class AdminResetPasswordViewModel + extends StateNotifier { + AdminResetPasswordViewModel(this._authRepository) + : super(const AdminResetPasswordState()); + + final AuthRepository _authRepository; + + Future resetCashierPassword({required String username}) async { + final trimmedUsername = username.trim(); + if (trimmedUsername.isEmpty) { + state = state.copyWith( + isLoading: false, + errorMessage: 'Cashier username is required', + clearSuccess: true, + ); + return; + } + + state = state.copyWith( + isLoading: true, + clearError: true, + clearSuccess: true, + ); + + try { + final responseMessage = await _authRepository.resetCashierPassword( + username: trimmedUsername, + ); + state = state.copyWith(isLoading: false, successMessage: responseMessage); + } catch (error) { + state = state.copyWith( + isLoading: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + void clearMessages() { + state = state.copyWith(clearError: true, clearSuccess: true); + } +} diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index 2f75560..ed33d57 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -1,214 +1,223 @@ -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; -import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart'; -import 'package:e_receipt_mobile/presentation/home/merchant_paging_view_model.dart'; -import 'package:e_receipt_mobile/presentation/home/widgets/home_drawer.dart'; -import 'package:e_receipt_mobile/presentation/home/widgets/merchant_header.dart'; -import 'package:e_receipt_mobile/presentation/home/widgets/merchant_list_view.dart'; -import 'package:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class HomeScreen extends ConsumerWidget { - const HomeScreen({required this.user, super.key}); - - final LoginUser user; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pagingState = ref.watch(merchantPagingViewModelProvider); - final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier); - final logoutState = ref.watch(logoutViewModelProvider); - final colorScheme = Theme.of(context).colorScheme; - final query = ref.watch(merchantSearchQueryProvider); - - return Scaffold( - appBar: AppBar( - title: const Text('Merchants'), - actions: [ - IconButton( - tooltip: 'Refresh', - onPressed: pagingState.isLoading - ? null - : () => pagingViewModel.refresh(), - icon: const Icon(Icons.refresh), - ), - ], - ), - drawer: HomeDrawer( - user: user, - onLogout: () async { - if (logoutState.isLoading) { - return; - } - final shouldLogout = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Logout'), - content: const Text('Are you sure you want to logout?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Logout'), - ), - ], - ); - }, - ); - - if (shouldLogout != true || !context.mounted) { - return; - } - - Navigator.of(context).pop(); // close drawer - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Signed out'), - behavior: SnackBarBehavior.floating, - ), - ); - await ref.read(logoutViewModelProvider.notifier).logout(); - }, - ), - body: pagingState.isLoading && pagingState.items.isEmpty - ? const Center(child: CircularProgressIndicator()) - : pagingState.errorMessage != null && pagingState.items.isEmpty - ? Center( - child: Padding( - padding: EdgeInsets.fromLTRB( - 24, - 24, - 24, - MediaQuery.viewPaddingOf(context).bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.wifi_off_outlined, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'Failed to load merchants', - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Text( - pagingState.errorMessage!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 14), - FilledButton.tonal( - onPressed: pagingViewModel.refresh, - child: const Text('Try again'), - ), - ], - ), - ), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MerchantHeader( - loadedCount: pagingState.items.length, - query: query, - onQueryChanged: (value) { - ref.read(merchantSearchQueryProvider.notifier).state = - value; - pagingViewModel.setSearchTerm(value); - }, - onClear: () { - ref.read(merchantSearchQueryProvider.notifier).state = ''; - pagingViewModel.setSearchTerm(''); - }, - ), - Expanded( - child: MerchantListView( - merchants: pagingState.items, - hasMore: pagingState.hasMore, - isLoadingMore: pagingState.isLoadingMore, - onRefresh: pagingViewModel.refresh, - onMerchantTap: (merchant) { - final id = merchant.id; - if (id == null) { - return; - } - _openTerminalSelection(context, id, merchant.name ?? '-'); - }, - onLoadMore: pagingViewModel.loadMore, - onEndReached: pagingViewModel.loadMore, - ), - ), - if (pagingState.errorMessage != null && - pagingState.items.isNotEmpty) - Padding( - padding: EdgeInsets.fromLTRB( - 16, - 8, - 16, - MediaQuery.viewPaddingOf(context).bottom + 8, - ), - child: Material( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: colorScheme.onErrorContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - pagingState.errorMessage!, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onErrorContainer, - ), - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: pagingViewModel.loadMore, - child: const Text('Retry'), - ), - ], - ), - ), - ), - ), - ], - ), - ); - } - - - - void _openTerminalSelection( - BuildContext context, - String merchantId, - String merchantName, - ) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TerminalSelectionScreen( - merchantId: merchantId, - merchantName: merchantName, - ), - ), - ); - } -} +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:e_receipt_mobile/presentation/admin_reset_password/admin_reset_password_page.dart'; +import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; +import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart'; +import 'package:e_receipt_mobile/presentation/home/merchant_paging_view_model.dart'; +import 'package:e_receipt_mobile/presentation/home/widgets/home_drawer.dart'; +import 'package:e_receipt_mobile/presentation/home/widgets/merchant_header.dart'; +import 'package:e_receipt_mobile/presentation/home/widgets/merchant_list_view.dart'; +import 'package:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class HomeScreen extends ConsumerWidget { + const HomeScreen({required this.user, super.key}); + + final LoginUser user; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pagingState = ref.watch(merchantPagingViewModelProvider); + final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier); + final logoutState = ref.watch(logoutViewModelProvider); + final colorScheme = Theme.of(context).colorScheme; + final query = ref.watch(merchantSearchQueryProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Merchants'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: pagingState.isLoading + ? null + : () => pagingViewModel.refresh(), + icon: const Icon(Icons.refresh), + ), + ], + ), + drawer: HomeDrawer( + user: user, + onResetCashierPassword: user.isAdmin + ? () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AdminResetPasswordPage(), + ), + ); + } + : null, + onLogout: () async { + if (logoutState.isLoading) { + return; + } + final shouldLogout = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Logout'), + ), + ], + ); + }, + ); + + if (shouldLogout != true || !context.mounted) { + return; + } + + Navigator.of(context).pop(); // close drawer + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Signed out'), + behavior: SnackBarBehavior.floating, + ), + ); + await ref.read(logoutViewModelProvider.notifier).logout(); + }, + ), + body: pagingState.isLoading && pagingState.items.isEmpty + ? const Center(child: CircularProgressIndicator()) + : pagingState.errorMessage != null && pagingState.items.isEmpty + ? Center( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + MediaQuery.viewPaddingOf(context).bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Failed to load merchants', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + pagingState.errorMessage!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: pagingViewModel.refresh, + child: const Text('Try again'), + ), + ], + ), + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MerchantHeader( + loadedCount: pagingState.items.length, + query: query, + onQueryChanged: (value) { + ref.read(merchantSearchQueryProvider.notifier).state = + value; + pagingViewModel.setSearchTerm(value); + }, + onClear: () { + ref.read(merchantSearchQueryProvider.notifier).state = ''; + pagingViewModel.setSearchTerm(''); + }, + ), + Expanded( + child: MerchantListView( + merchants: pagingState.items, + hasMore: pagingState.hasMore, + isLoadingMore: pagingState.isLoadingMore, + onRefresh: pagingViewModel.refresh, + onMerchantTap: (merchant) { + final id = merchant.id; + if (id == null) { + return; + } + _openTerminalSelection(context, id, merchant.name ?? '-'); + }, + onLoadMore: pagingViewModel.loadMore, + onEndReached: pagingViewModel.loadMore, + ), + ), + if (pagingState.errorMessage != null && + pagingState.items.isNotEmpty) + Padding( + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.viewPaddingOf(context).bottom + 8, + ), + child: Material( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + pagingState.errorMessage!, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: pagingViewModel.loadMore, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + void _openTerminalSelection( + BuildContext context, + String merchantId, + String merchantName, + ) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TerminalSelectionScreen( + merchantId: merchantId, + merchantName: merchantName, + ), + ), + ); + } +} diff --git a/lib/presentation/home/widgets/home_drawer.dart b/lib/presentation/home/widgets/home_drawer.dart index e422a88..aea770b 100644 --- a/lib/presentation/home/widgets/home_drawer.dart +++ b/lib/presentation/home/widgets/home_drawer.dart @@ -3,14 +3,16 @@ import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:flutter/material.dart'; class HomeDrawer extends StatelessWidget { - const HomeDrawer({ - required this.user, - required this.onLogout, - super.key, - }); - - final LoginUser user; - final VoidCallback onLogout; + const HomeDrawer({ + required this.user, + required this.onLogout, + this.onResetCashierPassword, + super.key, + }); + + final LoginUser user; + final VoidCallback onLogout; + final VoidCallback? onResetCashierPassword; @override Widget build(BuildContext context) { @@ -18,15 +20,24 @@ class HomeDrawer extends StatelessWidget { return Drawer( child: SafeArea( - child: Column( - children: [ - _DrawerHeader(user: user), - const Spacer(), - Divider( - height: 1, - thickness: 1, - color: colorScheme.outlineVariant.withOpacity(0.6), - ), + child: Column( + children: [ + _DrawerHeader(user: user), + if (user.isAdmin) + Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 0), + child: _DrawerItem( + icon: Icons.lock_reset_outlined, + label: 'Reset cashier password', + onTap: onResetCashierPassword ?? () {}, + ), + ), + const Spacer(), + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), child: _DrawerItem( diff --git a/lib/presentation/login/login_view_model.dart b/lib/presentation/login/login_view_model.dart index 048f5c5..d9b5971 100644 --- a/lib/presentation/login/login_view_model.dart +++ b/lib/presentation/login/login_view_model.dart @@ -67,6 +67,14 @@ final authRepositoryProvider = Provider((ref) { ); }); +final authenticatedAuthRepositoryProvider = Provider((ref) { + return ApiAuthRepository( + baseUrl: AppConfig.apiBaseUrl, + apiSecret: AppConfig.apiSecret, + client: ref.watch(authenticatedHttpClientProvider), + ); +}); + final loginViewModelProvider = StateNotifierProvider((ref) { return LoginViewModel( diff --git a/user.md b/user.md new file mode 100644 index 0000000..0a084ae --- /dev/null +++ b/user.md @@ -0,0 +1,102 @@ +# User API Documentation + +Base path: `/user` (mounted at `/user`) + +All routes require `keycloak.protect()` — Bearer token authentication via Keycloak. + +--- + +## POST /user/:username/cashier-reset-password + +**Purpose:** Reset a cashier's password to an auto-generated default. The default password is generated as: first 3 chars uppercase + 4th char lowercase + `@` + last 4 chars of username. The password is set as **temporary** (user must change on next login). + +### Path Parameters +| Name | Type | Required | Description | +|------|------|----------|-------------| +| username | string | Yes | Username of the cashier to reset | + +### Responses +| Code | Body | +|------|------| +| 200 | `{ "message": "Password reset successfully" }` | +| 400 | `{ "message": "Username is required" }` | +| 404 | `{ "message": "No user found" }` | +| 500 | `{ "message": "" }` | + +--- + +## POST /user/change-password + +**Purpose:** Authenticated user changes their own password. Verifies old password against Keycloak token endpoint, then sets the new password as **permanent**. + +### Request Body +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| oldPassword | string | Yes | Current password | +| newPassword | string | Yes | New password | +| confirmPassword | string | Yes | Must match newPassword | + +### Responses +| Code | Body | +|------|------| +| 200 | `{ "message": "Password changed successfully" }` | +| 400 | `{ "message": "All fields are required" }` or `"New passwords do not match"` or `"Invalid old password"` | +| 401 | `{ "message": "Unauthorized: User" }` | +| 404 | `{ "message": "No user found" }` | +| 500 | `{ "message": "" }` | + +--- + +## POST /user/ + +**Purpose:** Create a cashier user in Keycloak tied to a specific merchant. The username must correspond to an existing terminal serial in TMS for the given merchantId. + +### Middleware +- `keycloak.protect()` +- `createUserValidationRules` (express-validator) + +### Request Body +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| username | string | Yes | Alphanumeric, must exist as serial in TMS | +| password | string | Yes | Minimum 6 characters | +| role | string | Yes | One of: `"cashier"`, `"merchant"`, `"admin"` | +| merchantId | string | Yes | Merchant ID the user belongs to | + +### Responses +| Code | Body | +|------|------| +| 201 | `{ "message": "User created successfully", "userId": "" }` | +| 400 | `{ "errors": [...] }` (validation) or `{ "message": "Serials did not exist in TMS" }` | +| 500 | `{ "message": "" }` | + +--- + +## GET /user/merchant + +**Purpose:** Get all cashier/terminal users belonging to a merchant. Fetches terminal serials from TMS for the given merchantId, then returns the corresponding Keycloak user data for each serial. + +### Query Parameters +| Name | Type | Required | Description | +|------|------|----------|-------------| +| merchantId | string | Yes | Merchant ID to retrieve cashier users for | + +### Responses +| Code | Body | +|------|------| +| 200 | `[{ "id": "...", "username": "...", "email": null, "firstName": null, "lastName": null, "enabled": true }]` | +| 400 | `{ "message": "merchantId is required" }` | +| 500 | `{ "message": "" }` | + +--- + +## GET /user/admin + +**Purpose:** Get all admin users from Keycloak. Requires the authenticated user to have the `"admin"` client role. + +### Responses +| Code | Body | +|------|------| +| 200 | `[{ "id": "...", "username": "...", "email": "...", "firstName": "...", "lastName": "...", "enabled": true }]` | +| 400 | `{ "message": "permission is required" }` | +| 500 | `{ "message": "" }` |