diff --git a/docs/ux/auth-journeys.md b/docs/ux/auth-journeys.md index c1a365f..1c0ab81 100644 --- a/docs/ux/auth-journeys.md +++ b/docs/ux/auth-journeys.md @@ -149,3 +149,66 @@ text uses a normal text node to remain exposed to assistive technology. Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch `onboarding-12`. The final browser accessibility tree exposes the recipient text, demo status, status heading and recovery actions. PR is open for review. + +## Browser credential autofill + +Task: [#56](https://git.sendico.io/tech/sxchnge/issues/56), branch `auth-56`. +The consumer auth form groups its mounted inputs in one `AutofillGroup` and +declares their purpose explicitly: + +| Input | Flutter hints | Browser autocomplete | +| --- | --- | --- | +| Signup name | `name` with the name keyboard | `name` | +| Account email | `username`, then `email`, with the email keyboard | `username` | +| Login password | `password` | `current-password` | +| Signup password and confirmation | `newPassword` on both | `new-password` | + +Flutter uses the first hint on web and Apple platforms. Email is the credential +username; the email keyboard remains configured separately. Visibility toggles +keep the password role, so autofill does not depend on detecting masked input. +The confirmation field participates in the same group as the first password, +allowing the browser to fill both without copying one controller into the other. + +Signup acceptance finalizes autofill before clearing password controllers and +navigating to email verification. Login finalizes on success; when a second +factor is required, it waits for verification and login completion. The password +remains only in the existing form controller during that challenge, then clears +on completed navigation or cancellation. Invalid input, rejected credentials and +failed verification do not request saving. Switching auth mode, abandoning the +form or leaving for unverified-account recovery cancels the autofill context. +Provider/service/model boundaries and existing request/outcome telemetry are +unchanged; no field events or credential telemetry are added. Browser popup +display and pre-submit autofill abandonment remain outside current measurement. + +### Verification and release check + +Focused widget tests send a platform autofill response with all four signup +values and check EN/RU controller updates, grouped credential metadata, visibility +changes, mode switching and abandonment. Auth tests cover validation failures, +service rejection/retry, stale responses, signup/login acceptance, unverified +recovery, invalid second-factor codes and cancellation without premature saving. +Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI. + +Native Safari verification remains a release check: computer-control permission +was unavailable in this session. On the deployed HTTPS origin with Safari +AutoFill enabled, focus the signup email and password fields, choose a suggested +strong password, verify that both password fields populate, then confirm that +login offers the saved credential. Repeat after showing/hiding passwords and +switching modes. Browser settings, stored credentials and browser heuristics +determine whether suggestions appear; automated Flutter tests cannot establish +that the system popup was displayed or that credentials were saved. + +References: [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element), +[Flutter autofill hints](https://api.flutter.dev/flutter/material/TextField/autofillHints.html), +and [AutofillGroup](https://api.flutter.dev/flutter/widgets/AutofillGroup-class.html). + +Recorded validation: `dart format --output=none --set-exit-if-changed` passes for +all five changed Dart files; `flutter analyze` passes in `frontend/app`; +`make test-frontend` passes all 212 tests across the app and shared packages. +`flutter build web` passes. The built page's default rendering path was inspected +in the in-app browser: login exposes `username`/`current-password` in one HTML +form; signup exposes `username`, `name` and two `new-password` inputs in one form. +This verifies the browser metadata, not Safari's credential popup. With Flutter +web accessibility semantics enabled, unfocused semantic inputs initially use +framework-default autocomplete values until focused; password-manager behavior +with assistive technology still requires a native browser audit. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index a5171a0..315300f 100644 --- a/frontend/app/lib/auth/form.dart +++ b/frontend/app/lib/auth/form.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -63,6 +64,8 @@ class _AuthFormState extends State { void _switch(bool signUp) { if (signUp == _signUp) return; + FocusScope.of(context).unfocus(); + TextInput.finishAutofillContext(shouldSave: false); context.read().abandon(JourneyExit.modeChange); context.read().reset(); context.read().reset(); @@ -95,6 +98,7 @@ class _AuthFormState extends State { return; } final email = _email.text.trim(); + TextInput.finishAutofillContext(); _password.clear(); _confirmation.clear(); context.goNamed(AppRoutes.verificationSent, extra: email); @@ -107,13 +111,13 @@ class _AuthFormState extends State { ); if (!mounted || _signUp) return; if (login.state.error == AuthError.accountNotVerified) { + TextInput.finishAutofillContext(shouldSave: false); _password.clear(); context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim()); return; } final challenge = login.state.result?.challenge; if (challenge != null) { - _password.clear(); final grant = await verifyFactor( context, challenge: challenge, @@ -123,6 +127,8 @@ class _AuthFormState extends State { ); if (!mounted || _signUp) return; if (grant == null) { + TextInput.finishAutofillContext(shouldSave: false); + _password.clear(); login.reset(); context.read().abandon(JourneyExit.navigation); return; @@ -137,9 +143,13 @@ class _AuthFormState extends State { !_signUp && identical(login.state.result, accepted) && login.state.status == AuthStatus.success) { + TextInput.finishAutofillContext(); + _password.clear(); context.go(offer ? '/identity?onboarding=1' : '/accounts'); } } + } else if (login.state.status == AuthStatus.success) { + TextInput.finishAutofillContext(); } } } @@ -195,150 +205,169 @@ class _AuthFormState extends State { expandedInsets: EdgeInsets.zero, ), const SizedBox(height: 32), - Form( - key: _form, - autovalidateMode: _submitted - ? AutovalidateMode.onUserInteraction - : AutovalidateMode.disabled, - onChanged: () { - if (_provider.state.status == AuthStatus.success || - _provider.state.status == AuthStatus.error) { - _provider.reset(); - } - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_signUp) ...[ + AutofillGroup( + // Leaving an unfinished form must not offer to save its credentials. + onDisposeAction: AutofillContextAction.cancel, + child: Form( + key: _form, + autovalidateMode: _submitted + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + onChanged: () { + if (_provider.state.status == AuthStatus.success || + _provider.state.status == AuthStatus.error) { + _provider.reset(); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_signUp) ...[ + TextFormField( + key: const ValueKey('name'), + controller: _name, + enabled: !state.isLoading, + decoration: InputDecoration( + labelText: l.name, + prefixIcon: Icon(Icons.person_outline), + errorMaxLines: 3, + ), + textInputAction: TextInputAction.next, + keyboardType: TextInputType.name, + autofillHints: const [AutofillHints.name], + textCapitalization: TextCapitalization.words, + validator: (value) => AuthValidation.name(value, l), + ), + const SizedBox(height: 20), + ], TextFormField( - key: const ValueKey('name'), - controller: _name, + key: const ValueKey('email'), + controller: _email, enabled: !state.isLoading, decoration: InputDecoration( - labelText: l.name, - prefixIcon: Icon(Icons.person_outline), + labelText: l.email, + hintText: l.emailHint, + prefixIcon: Icon(Icons.alternate_email), errorMaxLines: 3, ), + keyboardType: TextInputType.emailAddress, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], textInputAction: TextInputAction.next, - textCapitalization: TextCapitalization.words, - validator: (value) => AuthValidation.name(value, l), + autocorrect: false, + validator: (value) => AuthValidation.email(value, l), ), const SizedBox(height: 20), - ], - TextFormField( - key: const ValueKey('email'), - controller: _email, - enabled: !state.isLoading, - decoration: InputDecoration( - labelText: l.email, - hintText: l.emailHint, - prefixIcon: Icon(Icons.alternate_email), - errorMaxLines: 3, - ), - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - autocorrect: false, - validator: (value) => AuthValidation.email(value, l), - ), - const SizedBox(height: 20), - TextFormField( - key: const ValueKey('password'), - enabled: !state.isLoading, - controller: _password, - obscureText: !_showPassword, - autocorrect: false, - enableSuggestions: false, - textInputAction: _signUp - ? TextInputAction.next - : TextInputAction.done, - onFieldSubmitted: (_) { - if (!_signUp) _submit(); - }, - decoration: InputDecoration( - labelText: l.password, - prefixIcon: const Icon(Icons.lock_outline), - errorMaxLines: 3, - helperText: _signUp ? l.passwordHint : null, - helperMaxLines: 3, - suffixIcon: IconButton( - tooltip: _showPassword ? l.hidePassword : l.showPassword, - onPressed: () => - setState(() => _showPassword = !_showPassword), - icon: Icon( - _showPassword - ? Icons.visibility_off_outlined - : Icons.visibility_outlined, - ), - ), - ), - validator: (value) => - AuthValidation.password(value, l, signUp: _signUp), - ), - if (_signUp) ...[ - const SizedBox(height: 20), TextFormField( - key: const ValueKey('confirmation'), + key: const ValueKey('password'), enabled: !state.isLoading, - controller: _confirmation, - obscureText: !_showConfirmation, + controller: _password, + obscureText: !_showPassword, + autofillHints: [ + _signUp + ? AutofillHints.newPassword + : AutofillHints.password, + ], autocorrect: false, enableSuggestions: false, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), + textInputAction: _signUp + ? TextInputAction.next + : TextInputAction.done, + onFieldSubmitted: (_) { + if (!_signUp) _submit(); + }, decoration: InputDecoration( - labelText: l.confirmPassword, + labelText: l.password, prefixIcon: const Icon(Icons.lock_outline), errorMaxLines: 3, + helperText: _signUp ? l.passwordHint : null, + helperMaxLines: 3, suffixIcon: IconButton( - tooltip: _showConfirmation - ? l.hideConfirmation - : l.showConfirmation, - onPressed: () => setState( - () => _showConfirmation = !_showConfirmation, - ), + tooltip: _showPassword ? l.hidePassword : l.showPassword, + onPressed: () => + setState(() => _showPassword = !_showPassword), icon: Icon( - _showConfirmation + _showPassword ? Icons.visibility_off_outlined : Icons.visibility_outlined, ), ), ), validator: (value) => - AuthValidation.confirmation(value, _password.text, l), + AuthValidation.password(value, l, signUp: _signUp), ), - ], - const SizedBox(height: 28), - FilledButton( - key: const ValueKey('submit'), - onPressed: state.isLoading || state.status == AuthStatus.success - ? null - : _submit, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Text( - state.isLoading - ? l.authWorking - : (_signUp ? l.createAccount : l.letsGo), - textAlign: TextAlign.center, + if (_signUp) ...[ + const SizedBox(height: 20), + TextFormField( + key: const ValueKey('confirmation'), + enabled: !state.isLoading, + controller: _confirmation, + obscureText: !_showConfirmation, + autofillHints: const [AutofillHints.newPassword], + autocorrect: false, + enableSuggestions: false, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: l.confirmPassword, + prefixIcon: const Icon(Icons.lock_outline), + errorMaxLines: 3, + suffixIcon: IconButton( + tooltip: _showConfirmation + ? l.hideConfirmation + : l.showConfirmation, + onPressed: () => setState( + () => _showConfirmation = !_showConfirmation, + ), + icon: Icon( + _showConfirmation + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, ), ), - const SizedBox(width: 12), - state.isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.arrow_forward, size: 20), - ], + ), + validator: (value) => + AuthValidation.confirmation(value, _password.text, l), + ), + ], + const SizedBox(height: 28), + FilledButton( + key: const ValueKey('submit'), + onPressed: + state.isLoading || state.status == AuthStatus.success + ? null + : _submit, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + state.isLoading + ? l.authWorking + : (_signUp ? l.createAccount : l.letsGo), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + state.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.arrow_forward, size: 20), + ], + ), ), ), - ), - ], + ], + ), ), ), if (state.status == AuthStatus.success || diff --git a/frontend/app/test/auth_services_test.dart b/frontend/app/test/auth_services_test.dart index 4962775..edc6c5e 100644 --- a/frontend/app/test/auth_services_test.dart +++ b/frontend/app/test/auth_services_test.dart @@ -92,6 +92,14 @@ void main() { findsOneWidget, ); expect(find.byKey(const ValueKey('verification-sent')), findsNothing); + expect( + tester.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); expect( tester .widget(find.byKey(const ValueKey('email'))) @@ -153,6 +161,14 @@ void main() { '/verification-sent', ); expect(backend.sends, 1); + expect( + tester.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); expect( find.textContaining('a verification email is on its way'), findsOneWidget, @@ -198,6 +214,14 @@ void main() { find.text('Сервис недоступен. Попробуйте ещё раз.'), findsOneWidget, ); + expect( + tester.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); await tester.tap(button); await tester.pump(); expect(service.calls, hasLength(2)); @@ -207,6 +231,12 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); expect(find.byKey(const ValueKey('auth-error')), findsNothing); + expect( + tester.testTextInput.log, + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ); expect(tester.takeException(), isNull); }, ); @@ -237,6 +267,14 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('confirmation')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsNothing); + expect( + tester.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); expect(tester.takeException(), isNull); }); } diff --git a/frontend/app/test/auth_test.dart b/frontend/app/test/auth_test.dart index ffc947b..31a5784 100644 --- a/frontend/app/test/auth_test.dart +++ b/frontend/app/test/auth_test.dart @@ -55,6 +55,12 @@ void main() { await submit(tester); expect(find.text('Enter your email address.'), findsOneWidget); expect(find.text('Enter your password.'), findsOneWidget); + expect( + tester.testTextInput.log.where( + (call) => call.method == 'TextInput.finishAutofillContext', + ), + isEmpty, + ); await tester.enterText( find.byKey(const ValueKey('email')), 'test@example.com', @@ -79,6 +85,12 @@ void main() { await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); + expect( + tester.testTextInput.log, + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ); expect(tester.takeException(), isNull); }, ); @@ -104,12 +116,26 @@ void main() { ); await submit(tester); expect(find.text('Passwords do not match.'), findsOneWidget); + expect( + tester.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); await tester.enterText( find.byKey(const ValueKey('confirmation')), 'example-password', ); await submit(tester); expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); + expect( + tester.testTextInput.log, + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ); await tester.ensureVisible( find.byKey(const ValueKey('demo-confirmation')), ); diff --git a/frontend/app/test/autofill_test.dart b/frontend/app/test/autofill_test.dart new file mode 100644 index 0000000..1cd8161 --- /dev/null +++ b/frontend/app/test/autofill_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; + +Finder field(String key) => find.byKey(ValueKey(key)); + +EditableTextState editor(WidgetTester tester, String key) => + tester.state( + find.descendant(of: field(key), matching: find.byType(EditableText)), + ); + +void main() { + for (final language in ['en', 'ru']) { + testWidgets( + 'platform autofill fills the signup credential group ($language)', + (tester) async { + tester.view.physicalSize = const Size(1440, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + SxchngeApp( + localeProvider: LocaleProvider(initialLocale: Locale(language)), + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.text(language == 'ru' ? 'Регистрация' : 'Sign up'), + ); + await tester.pumpAndSettle(); + await tester.showKeyboard(field('email')); + + final configuration = tester.testTextInput.setClientArgs!; + expect( + configuration['inputType']['name'], + 'TextInputType.emailAddress', + ); + final fields = (configuration['fields'] as List).cast(); + expect(fields, hasLength(4)); + expect( + fields.map((field) => field['autofill']['hints'].first), + containsAll(['name', 'username', 'newPassword', 'newPassword']), + ); + + const values = { + 'name': 'Alex Example', + 'email': 'alex@example.com', + 'password': 'generated-example-password', + 'confirmation': 'generated-example-password', + }; + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall('TextInputClient.updateEditingStateWithTag', [ + 0, + { + for (final entry in values.entries) + editor(tester, entry.key).autofillId: TextEditingValue( + text: entry.value, + ).toJSON(), + }, + ]), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + for (final entry in values.entries) { + expect( + editor(tester, entry.key).currentTextEditingValue.text, + entry.value, + ); + } + + // A visibility change must retain both the supplied value and its role. + await tester.tap( + find.byTooltip( + language == 'ru' ? 'Показать пароль' : 'Show password', + ), + ); + await tester.pump(); + expect(editor(tester, 'password').widget.obscureText, isFalse); + expect(editor(tester, 'password').widget.autofillHints, [ + 'newPassword', + ]); + expect( + editor(tester, 'password').currentTextEditingValue.text, + values['password'], + ); + + await tester.tap(find.text(language == 'ru' ? 'Войти' : 'Log in')); + await tester.pumpAndSettle(); + await tester.showKeyboard(field('email')); + final loginFields = + (tester.testTextInput.setClientArgs!['fields'] as List).cast(); + expect(loginFields, hasLength(2)); + expect(loginFields.map((field) => field['autofill']['hints'].first), [ + 'username', + 'password', + ]); + expect( + editor(tester, 'password').currentTextEditingValue.text, + isEmpty, + ); + expect( + tester.testTextInput.log, + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: false), + ), + ); + expect( + tester.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); + }, + ); + } + + testWidgets('leaving an unfinished auth form cancels saving', (tester) async { + await tester.pumpWidget(const SxchngeApp()); + await tester.pumpAndSettle(); + await tester.enterText(field('email'), 'alex@example.com'); + await tester.enterText(field('password'), 'unfinished-password'); + tester.testTextInput.log.clear(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect( + tester.testTextInput.log, + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: false), + ), + ); + expect( + tester.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); + }); +} diff --git a/frontend/app/test/two_factor_test.dart b/frontend/app/test/two_factor_test.dart index b7baa4c..f2b3081 100644 --- a/frontend/app/test/two_factor_test.dart +++ b/frontend/app/test/two_factor_test.dart @@ -148,13 +148,38 @@ void main() { await signIn(t); expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsNothing); + expect( + t.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); expect( analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal), isEmpty, ); + await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000'); + await tapFactor(t, 'two-factor-confirm'); + expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget); + expect( + t.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456'); await tapFactor(t, 'two-factor-confirm'); expect(find.byKey(const ValueKey('account-eur')), findsOneWidget); + expect( + t.testTextInput.log, + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ); final login = analytics.events .where((e) => e.kind == JourneyKind.login) .toList(); @@ -172,6 +197,20 @@ void main() { await signIn(t); await tapFactor(t, 'two-factor-cancel'); expect(find.byKey(const ValueKey('email')), findsOneWidget); + expect( + t.testTextInput.log, + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: false), + ), + ); + expect( + t.testTextInput.log, + isNot( + contains( + isMethodCall('TextInput.finishAutofillContext', arguments: true), + ), + ), + ); expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect( t