From 68b75c80432ba3277aeee85b1aaa3bf7f76c6a4c Mon Sep 17 00:00:00 2001 From: provokateurin Date: Sun, 28 Jul 2024 16:21:25 +0200 Subject: [PATCH] feat(neon_framework): Enable user password confirmation Signed-off-by: provokateurin --- .cspell/dart_flutter.txt | 1 + packages/neon_framework/lib/l10n/en.arb | 5 +- .../lib/l10n/localizations.dart | 18 +++ .../lib/l10n/localizations_en.dart | 9 ++ .../lib/src/utils/password_confirmation.dart | 36 ++++++ .../lib/src/widgets/dialog.dart | 102 ++++++++++++++++ packages/neon_framework/lib/widgets.dart | 7 +- packages/neon_framework/test/dialog_test.dart | 84 ++++++++++++++ .../test/password_confirmation_test.dart | 109 ++++++++++++++++++ 9 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 packages/neon_framework/lib/src/utils/password_confirmation.dart create mode 100644 packages/neon_framework/test/password_confirmation_test.dart diff --git a/.cspell/dart_flutter.txt b/.cspell/dart_flutter.txt index caaec313796..544e49b6364 100644 --- a/.cspell/dart_flutter.txt +++ b/.cspell/dart_flutter.txt @@ -19,3 +19,4 @@ unfocus writeln xmark arrowshape +autocorrect diff --git a/packages/neon_framework/lib/l10n/en.arb b/packages/neon_framework/lib/l10n/en.arb index 4cdc22f24f7..7d8af298bfc 100644 --- a/packages/neon_framework/lib/l10n/en.arb +++ b/packages/neon_framework/lib/l10n/en.arb @@ -78,6 +78,8 @@ } } }, + "errorUserPasswordConfirmationRequired": "You need to confirm your user password", + "errorWrongUserPassword": "Wrong user password", "errorDialog": "An error has occurred", "actionYes": "Yes", "actionNo": "No", @@ -278,5 +280,6 @@ "userStatusClearAtThisWeek": "This week", "userStatusActionClear": "Clear status", "userStatusStatusMessage": "Status message", - "userStatusOnlineStatus": "Online status" + "userStatusOnlineStatus": "Online status", + "passwordConfirmationUserPassword": "Your user password" } diff --git a/packages/neon_framework/lib/l10n/localizations.dart b/packages/neon_framework/lib/l10n/localizations.dart index e6d35f53533..c896bca47f0 100644 --- a/packages/neon_framework/lib/l10n/localizations.dart +++ b/packages/neon_framework/lib/l10n/localizations.dart @@ -275,6 +275,18 @@ abstract class NeonLocalizations { /// **'Route not found: {route}'** String errorRouteNotFound(String route); + /// No description provided for @errorUserPasswordConfirmationRequired. + /// + /// In en, this message translates to: + /// **'You need to confirm your user password'** + String get errorUserPasswordConfirmationRequired; + + /// No description provided for @errorWrongUserPassword. + /// + /// In en, this message translates to: + /// **'Wrong user password'** + String get errorWrongUserPassword; + /// No description provided for @errorDialog. /// /// In en, this message translates to: @@ -868,6 +880,12 @@ abstract class NeonLocalizations { /// In en, this message translates to: /// **'Online status'** String get userStatusOnlineStatus; + + /// No description provided for @passwordConfirmationUserPassword. + /// + /// In en, this message translates to: + /// **'Your user password'** + String get passwordConfirmationUserPassword; } class _NeonLocalizationsDelegate extends LocalizationsDelegate { diff --git a/packages/neon_framework/lib/l10n/localizations_en.dart b/packages/neon_framework/lib/l10n/localizations_en.dart index 095105424c5..aaf27a653b9 100644 --- a/packages/neon_framework/lib/l10n/localizations_en.dart +++ b/packages/neon_framework/lib/l10n/localizations_en.dart @@ -130,6 +130,12 @@ class NeonLocalizationsEn extends NeonLocalizations { return 'Route not found: $route'; } + @override + String get errorUserPasswordConfirmationRequired => 'You need to confirm your user password'; + + @override + String get errorWrongUserPassword => 'Wrong user password'; + @override String get errorDialog => 'An error has occurred'; @@ -505,4 +511,7 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get userStatusOnlineStatus => 'Online status'; + + @override + String get passwordConfirmationUserPassword => 'Your user password'; } diff --git a/packages/neon_framework/lib/src/utils/password_confirmation.dart b/packages/neon_framework/lib/src/utils/password_confirmation.dart new file mode 100644 index 00000000000..c67a9aff31f --- /dev/null +++ b/packages/neon_framework/lib/src/utils/password_confirmation.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/utils.dart'; + +/// For testing using `MockAccount` is just fine because each of them will have a different hashCode and will not interfere with existing state. +Map _lastPasswordConfirmations = {}; + +/// Confirms the user password if necessary. +/// +/// Returns `true` if not necessary or successful. +/// Returns `false` if the user aborted or it was unsuccessful. +Future confirmPassword( + BuildContext context, { + @visibleForTesting DateTime? now, +}) async { + final account = NeonProvider.of(context); + final lastConfirmation = _lastPasswordConfirmations[account]; + if (lastConfirmation != null && (now ?? DateTime.now()).difference(lastConfirmation) < const Duration(minutes: 30)) { + return true; + } + + final result = await showAdaptiveDialog( + context: context, + builder: (context) => NeonPasswordConfirmationDialog( + account: account, + ), + ); + + if (result == null || !result) { + return false; + } + + _lastPasswordConfirmations[account] = now ?? DateTime.now(); + return true; +} diff --git a/packages/neon_framework/lib/src/widgets/dialog.dart b/packages/neon_framework/lib/src/widgets/dialog.dart index 896e339cfa7..b3f0f81d2bc 100644 --- a/packages/neon_framework/lib/src/widgets/dialog.dart +++ b/packages/neon_framework/lib/src/widgets/dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:built_collection/built_collection.dart'; +import 'package:dynamite_runtime/http_client.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -1137,3 +1138,104 @@ class _NeonUserStatusDialogState extends State { ); } } + +@internal +class NeonPasswordConfirmationDialog extends StatefulWidget { + const NeonPasswordConfirmationDialog({ + required this.account, + super.key, + }); + + final Account account; + + @override + State createState() => _NeonPasswordConfirmationDialogState(); +} + +class _NeonPasswordConfirmationDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + final focusNode = FocusNode(); + bool wrongPassword = false; + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + + super.dispose(); + } + + Future submit() async { + setState(() { + wrongPassword = false; + }); + + if (formKey.currentState!.validate()) { + try { + await widget.account.client.core.appPassword.confirmUserPassword( + $body: core.AppPasswordConfirmUserPasswordRequestApplicationJson( + (b) => b.password = controller.text, + ), + ); + + if (mounted) { + Navigator.of(context).pop(true); + } + } on Exception catch (error) { + // Do not log here as error messages could contain the user password + + if (error case DynamiteStatusCodeException(statusCode: 403)) { + setState(() { + wrongPassword = true; + }); + + controller.clear(); + focusNode.requestFocus(); + + return; + } + + if (mounted) { + NeonError.showSnackbar(context, error); + } + } + } + } + + @override + Widget build(BuildContext context) { + return NeonDialog( + title: Text(NeonLocalizations.of(context).errorUserPasswordConfirmationRequired), + icon: const Icon(Icons.password), + content: Form( + key: formKey, + child: TextFormField( + obscureText: true, + enableSuggestions: false, + autocorrect: false, + autofocus: true, + controller: controller, + focusNode: focusNode, + textInputAction: TextInputAction.continueAction, + decoration: InputDecoration( + hintText: NeonLocalizations.of(context).passwordConfirmationUserPassword, + errorText: wrongPassword ? NeonLocalizations.of(context).errorWrongUserPassword : null, + suffixIconConstraints: BoxConstraints.tight(const Size(32, 24)), + ), + validator: (input) => validateNotEmpty(context, input), + onFieldSubmitted: (_) async { + await submit(); + }, + ), + ), + actions: [ + NeonDialogAction( + onPressed: submit, + isDefaultAction: true, + child: Text(NeonLocalizations.of(context).actionContinue), + ), + ], + ); + } +} diff --git a/packages/neon_framework/lib/widgets.dart b/packages/neon_framework/lib/widgets.dart index 7813ed5e50d..ebf37c33c21 100644 --- a/packages/neon_framework/lib/widgets.dart +++ b/packages/neon_framework/lib/widgets.dart @@ -1,7 +1,12 @@ export 'package:neon_framework/src/widgets/autocomplete.dart'; export 'package:neon_framework/src/widgets/custom_background.dart'; export 'package:neon_framework/src/widgets/dialog.dart' - hide AccountDeletion, NeonAccountDeletionDialog, NeonAccountSelectionDialog, NeonUnifiedPushDialog; + hide + AccountDeletion, + NeonAccountDeletionDialog, + NeonAccountSelectionDialog, + NeonPasswordConfirmationDialog, + NeonUnifiedPushDialog; export 'package:neon_framework/src/widgets/error.dart'; export 'package:neon_framework/src/widgets/image.dart' hide NeonImage; export 'package:neon_framework/src/widgets/linear_progress_indicator.dart'; diff --git a/packages/neon_framework/test/dialog_test.dart b/packages/neon_framework/test/dialog_test.dart index 2c3cbe45646..44fa040ebef 100644 --- a/packages/neon_framework/test/dialog_test.dart +++ b/packages/neon_framework/test/dialog_test.dart @@ -1,9 +1,13 @@ +import 'dart:convert'; + import 'package:built_collection/built_collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/l10n/localizations_en.dart'; +import 'package:neon_framework/models.dart'; import 'package:neon_framework/src/widgets/dialog.dart'; import 'package:neon_framework/testing.dart'; import 'package:neon_framework/utils.dart'; @@ -15,6 +19,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:timezone/timezone.dart' as tz; void main() { + setUpAll(() { + registerFallbackValue(MaterialPageRoute(builder: (_) => const SizedBox()) as Route); + + FakeNeonStorage.setup(); + }); + group('dialog', () { group('NeonConfirmationDialog', () { testWidgets('NeonConfirmationDialog widget', (tester) async { @@ -529,4 +539,78 @@ void main() { }); }); }); + + group('NeonPasswordConfirmationDialog', () { + late final Account account; + + setUpAll(() { + account = mockServer({ + RegExp(r'/ocs/v2\.php/core/apppassword/confirm'): { + 'put': (match, bodyBytes) { + final data = json.decode(utf8.decode(bodyBytes)) as Map; + final password = data['password'] as String; + + return http.Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': { + 'lastLogin': 0, + }, + }, + }), + password == 'correct' ? 200 : 403, + headers: {'content-type': 'application/json'}, + ); + }, + }, + }); + }); + + testWidgets('Empty password', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + child: NeonPasswordConfirmationDialog( + account: account, + ), + ), + ); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.text(NeonLocalizationsEn().errorEmptyField), findsOne); + }); + + testWidgets('Wrong password', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + child: NeonPasswordConfirmationDialog( + account: account, + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'wrong'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.text(NeonLocalizationsEn().errorWrongUserPassword), findsOne); + }); + + testWidgets('Correct password', (tester) async { + final navigatorObserver = MockNavigatorObserver(); + await tester.pumpWidgetWithAccessibility( + TestApp( + navigatorObserver: navigatorObserver, + child: NeonPasswordConfirmationDialog( + account: account, + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'correct'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + verify(() => navigatorObserver.didPop(any(), any())).called(1); + }); + }); } diff --git a/packages/neon_framework/test/password_confirmation_test.dart b/packages/neon_framework/test/password_confirmation_test.dart new file mode 100644 index 00000000000..bceae965508 --- /dev/null +++ b/packages/neon_framework/test/password_confirmation_test.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/src/utils/password_confirmation.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/testing.dart'; +import 'package:provider/provider.dart'; + +void main() { + final now = DateTime.timestamp(); + late Account account; + + setUp(() { + account = mockServer({ + RegExp(r'/ocs/v2\.php/core/apppassword/confirm'): { + 'put': (match, bodyBytes) => http.Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': { + 'lastLogin': 0, + }, + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ), + }, + }); + + FakeNeonStorage.setup(); + }); + + testWidgets('Success', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + providers: [ + Provider.value(value: account), + ], + child: const SizedBox(), + ), + ); + final context = tester.element(find.byType(SizedBox)); + + var future = confirmPassword( + context, + now: now.subtract(const Duration(minutes: 29)), + ); + await tester.pumpAndSettle(); + expect(find.byType(NeonPasswordConfirmationDialog), findsOne); + + await tester.runAsync(() async { + await tester.enterText(find.byType(TextFormField), 'password'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + }); + + expect(await future, true); + + expect( + await confirmPassword( + context, + now: now, + ), + true, + ); + + future = confirmPassword( + context, + now: now.add(const Duration(minutes: 1)), + ); + await tester.pumpAndSettle(); + expect(find.byType(NeonPasswordConfirmationDialog), findsOne); + + await tester.runAsync(() async { + await tester.enterText(find.byType(TextFormField), 'password'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + }); + + expect(await future, true); + }); + + testWidgets('Cancel', (tester) async { + await tester.pumpWidgetWithAccessibility( + TestApp( + providers: [ + Provider.value(value: account), + ], + child: const SizedBox(), + ), + ); + final context = tester.element(find.byType(SizedBox)); + + final future = confirmPassword( + context, + now: now.subtract(const Duration(minutes: 29)), + ); + await tester.pumpAndSettle(); + expect(find.byType(NeonPasswordConfirmationDialog), findsOne); + + Navigator.of(context).pop(); + + expect(await future, false); + }); +}