Skip to content

Commit

Permalink
feat(neon_framework): Enable user password confirmation
Browse files Browse the repository at this point in the history
Signed-off-by: provokateurin <[email protected]>
  • Loading branch information
provokateurin committed Aug 4, 2024
1 parent e9233c4 commit 68b75c8
Show file tree
Hide file tree
Showing 9 changed files with 369 additions and 2 deletions.
1 change: 1 addition & 0 deletions .cspell/dart_flutter.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ unfocus
writeln
xmark
arrowshape
autocorrect
5 changes: 4 additions & 1 deletion packages/neon_framework/lib/l10n/en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -278,5 +280,6 @@
"userStatusClearAtThisWeek": "This week",
"userStatusActionClear": "Clear status",
"userStatusStatusMessage": "Status message",
"userStatusOnlineStatus": "Online status"
"userStatusOnlineStatus": "Online status",
"passwordConfirmationUserPassword": "Your user password"
}
18 changes: 18 additions & 0 deletions packages/neon_framework/lib/l10n/localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<NeonLocalizations> {
Expand Down
9 changes: 9 additions & 0 deletions packages/neon_framework/lib/l10n/localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -505,4 +511,7 @@ class NeonLocalizationsEn extends NeonLocalizations {

@override
String get userStatusOnlineStatus => 'Online status';

@override
String get passwordConfirmationUserPassword => 'Your user password';
}
36 changes: 36 additions & 0 deletions packages/neon_framework/lib/src/utils/password_confirmation.dart
Original file line number Diff line number Diff line change
@@ -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<Account, DateTime> _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<bool> confirmPassword(
BuildContext context, {
@visibleForTesting DateTime? now,
}) async {
final account = NeonProvider.of<Account>(context);
final lastConfirmation = _lastPasswordConfirmations[account];
if (lastConfirmation != null && (now ?? DateTime.now()).difference(lastConfirmation) < const Duration(minutes: 30)) {
return true;
}

final result = await showAdaptiveDialog<bool?>(
context: context,
builder: (context) => NeonPasswordConfirmationDialog(
account: account,
),
);

if (result == null || !result) {
return false;
}

_lastPasswordConfirmations[account] = now ?? DateTime.now();
return true;
}
102 changes: 102 additions & 0 deletions packages/neon_framework/lib/src/widgets/dialog.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -1137,3 +1138,104 @@ class _NeonUserStatusDialogState extends State<NeonUserStatusDialog> {
);
}
}

@internal
class NeonPasswordConfirmationDialog extends StatefulWidget {
const NeonPasswordConfirmationDialog({
required this.account,
super.key,
});

final Account account;

@override
State<NeonPasswordConfirmationDialog> createState() => _NeonPasswordConfirmationDialogState();
}

class _NeonPasswordConfirmationDialogState extends State<NeonPasswordConfirmationDialog> {
final formKey = GlobalKey<FormState>();
final controller = TextEditingController();
final focusNode = FocusNode();
bool wrongPassword = false;

@override
void dispose() {
controller.dispose();
focusNode.dispose();

super.dispose();
}

Future<void> 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),
),
],
);
}
}
7 changes: 6 additions & 1 deletion packages/neon_framework/lib/widgets.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
84 changes: 84 additions & 0 deletions packages/neon_framework/test/dialog_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +19,12 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:timezone/timezone.dart' as tz;

void main() {
setUpAll(() {
registerFallbackValue(MaterialPageRoute<dynamic>(builder: (_) => const SizedBox()) as Route<dynamic>);

FakeNeonStorage.setup();
});

group('dialog', () {
group('NeonConfirmationDialog', () {
testWidgets('NeonConfirmationDialog widget', (tester) async {
Expand Down Expand Up @@ -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<String, dynamic>;
final password = data['password'] as String;

return http.Response(
json.encode({
'ocs': {
'meta': {'status': '', 'statuscode': 0},
'data': <dynamic, dynamic>{
'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);
});
});
}
Loading

0 comments on commit 68b75c8

Please sign in to comment.