diff --git a/lib/desktop/fido/state.dart b/lib/desktop/fido/state.dart index c3bdf2d3..d974f199 100755 --- a/lib/desktop/fido/state.dart +++ b/lib/desktop/fido/state.dart @@ -32,14 +32,18 @@ import '../state.dart'; final _log = Logger('desktop.fido.state'); -final _pinProvider = StateProvider.autoDispose.family( - (ref, _) => null, +final _pinProvider = StateProvider.family( + (ref, _) { + // Clear PIN if current device is changed + ref.watch(currentDeviceProvider); + return null; + }, ); final _sessionProvider = Provider.autoDispose.family( (ref, devicePath) { - // Make sure the pinProvider is held for the duration of the session. + // Refresh state when PIN is changed ref.watch(_pinProvider(devicePath)); return RpcNodeSession( ref.watch(rpcProvider).requireValue, devicePath, ['fido', 'ctap2']); diff --git a/lib/fido/views/add_fingerprint_dialog.dart b/lib/fido/views/add_fingerprint_dialog.dart index c4daafc5..31638b3f 100755 --- a/lib/fido/views/add_fingerprint_dialog.dart +++ b/lib/fido/views/add_fingerprint_dialog.dart @@ -256,7 +256,7 @@ class _AddFingerprintDialogState extends ConsumerState onFieldSubmitted: (_) { _submit(); }, - ), + ).init(), ) ] ], diff --git a/lib/fido/views/pin_dialog.dart b/lib/fido/views/pin_dialog.dart index 57c624a0..10b44b50 100755 --- a/lib/fido/views/pin_dialog.dart +++ b/lib/fido/views/pin_dialog.dart @@ -44,7 +44,8 @@ class FidoPinDialog extends ConsumerStatefulWidget { } class _FidoPinDialogState extends ConsumerState { - String _currentPin = ''; + final _currentPinController = TextEditingController(); + final _currentPinFocus = FocusNode(); String _newPin = ''; String _confirmPin = ''; String? _currentPinError; @@ -54,15 +55,28 @@ class _FidoPinDialogState extends ConsumerState { bool _isObscureCurrent = true; bool _isObscureNew = true; bool _isObscureConfirm = true; + bool _isBlocked = false; + + @override + void dispose() { + _currentPinController.dispose(); + _currentPinFocus.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final hasPin = widget.state.hasPin; - final isValid = _newPin.isNotEmpty && - _newPin == _confirmPin && - (!hasPin || _currentPin.isNotEmpty); final minPinLength = widget.state.minPinLength; + final currentMinPinLen = !hasPin + ? 0 + // N.B. current PIN may be shorter than minimum if set before the minimum was increased + : (widget.state.forcePinChange ? 4 : widget.state.minPinLength); + final currentPinLenOk = + _currentPinController.text.length >= currentMinPinLen; + final newPinLenOk = _newPin.length >= minPinLength; + final isValid = currentPinLenOk && newPinLenOk && _newPin == _confirmPin; return ResponsiveDialog( title: Text(hasPin ? l10n.s_change_pin : l10n.s_set_pin), @@ -82,11 +96,13 @@ class _FidoPinDialogState extends ConsumerState { Text(l10n.p_enter_current_pin_or_reset_no_puk), AppTextFormField( key: currentPin, - initialValue: _currentPin, + controller: _currentPinController, + focusNode: _currentPinFocus, autofocus: true, obscureText: _isObscureCurrent, autofillHints: const [AutofillHints.password], decoration: AppInputDecoration( + enabled: !_isBlocked, border: const OutlineInputBorder(), labelText: l10n.s_current_pin, errorText: _currentIsWrong ? _currentPinError : null, @@ -108,10 +124,9 @@ class _FidoPinDialogState extends ConsumerState { onChanged: (value) { setState(() { _currentIsWrong = false; - _currentPin = value; }); }, - ), + ).init(), ], Text(l10n.p_enter_new_fido2_pin(minPinLength)), // TODO: Set max characters based on UTF-8 bytes @@ -124,7 +139,7 @@ class _FidoPinDialogState extends ConsumerState { decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_new_pin, - enabled: !hasPin || _currentPin.isNotEmpty, + enabled: !_isBlocked && currentPinLenOk, errorText: _newIsWrong ? _newPinError : null, errorMaxLines: 3, prefixIcon: const Icon(Symbols.pin), @@ -146,7 +161,7 @@ class _FidoPinDialogState extends ConsumerState { _newPin = value; }); }, - ), + ).init(), AppTextFormField( key: confirmPin, initialValue: _confirmPin, @@ -168,8 +183,12 @@ class _FidoPinDialogState extends ConsumerState { tooltip: _isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin, ), - enabled: - (!hasPin || _currentPin.isNotEmpty) && _newPin.isNotEmpty, + enabled: !_isBlocked && currentPinLenOk && newPinLenOk, + errorText: _newPin.length == _confirmPin.length && + _newPin != _confirmPin + ? l10n.l_pin_mismatch + : null, + helperText: '', // Prevents resizing when errorText shown ), onChanged: (value) { setState(() { @@ -181,7 +200,7 @@ class _FidoPinDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -195,15 +214,9 @@ class _FidoPinDialogState extends ConsumerState { void _submit() async { final l10n = AppLocalizations.of(context)!; - final minPinLength = widget.state.minPinLength; - final oldPin = _currentPin.isNotEmpty ? _currentPin : null; - if (_newPin.length < minPinLength) { - setState(() { - _newPinError = l10n.l_new_pin_len(minPinLength); - _newIsWrong = true; - }); - return; - } + final oldPin = _currentPinController.text.isNotEmpty + ? _currentPinController.text + : null; try { final result = await ref .read(fidoStateProvider(widget.devicePath).notifier) @@ -213,9 +226,13 @@ class _FidoPinDialogState extends ConsumerState { showMessage(context, l10n.s_pin_set); }, failed: (retries, authBlocked) { setState(() { + _currentPinController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentPinController.text.length); + _currentPinFocus.requestFocus(); if (authBlocked) { _currentPinError = l10n.l_pin_soft_locked; _currentIsWrong = true; + _isBlocked = true; } else { _currentPinError = l10n.l_wrong_pin_attempts_remaining(retries); _currentIsWrong = true; diff --git a/lib/fido/views/pin_entry_form.dart b/lib/fido/views/pin_entry_form.dart index 653c168f..b4236fa8 100644 --- a/lib/fido/views/pin_entry_form.dart +++ b/lib/fido/views/pin_entry_form.dart @@ -37,11 +37,19 @@ class PinEntryForm extends ConsumerStatefulWidget { class _PinEntryFormState extends ConsumerState { final _pinController = TextEditingController(); + final _pinFocus = FocusNode(); bool _blocked = false; int? _retries; bool _pinIsWrong = false; bool _isObscure = true; + @override + void dispose() { + _pinController.dispose(); + _pinFocus.dispose(); + super.dispose(); + } + void _submit() async { setState(() { _pinIsWrong = false; @@ -51,8 +59,10 @@ class _PinEntryFormState extends ConsumerState { .read(fidoStateProvider(widget._deviceNode.path).notifier) .unlock(_pinController.text); result.whenOrNull(failed: (retries, authBlocked) { + _pinController.selection = TextSelection( + baseOffset: 0, extentOffset: _pinController.text.length); + _pinFocus.requestFocus(); setState(() { - _pinController.clear(); _pinIsWrong = true; _retries = retries; _blocked = authBlocked; @@ -92,6 +102,8 @@ class _PinEntryFormState extends ConsumerState { obscureText: _isObscure, autofillHints: const [AutofillHints.password], controller: _pinController, + focusNode: _pinFocus, + enabled: !_blocked && (_retries ?? 1) > 0, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, @@ -116,7 +128,7 @@ class _PinEntryFormState extends ConsumerState { }); }, // Update state on change onSubmitted: (_) => _submit(), - ), + ).init(), ), ListTile( leading: noFingerprints @@ -136,8 +148,12 @@ class _PinEntryFormState extends ConsumerState { key: unlockFido2WithPin, icon: const Icon(Symbols.lock_open), label: Text(l10n.s_unlock), - onPressed: - _pinController.text.isNotEmpty && !_blocked ? _submit : null, + onPressed: !_pinIsWrong && + _pinController.text.length >= + widget._state.minPinLength && + !_blocked + ? _submit + : null, ), ), ], diff --git a/lib/fido/views/rename_fingerprint_dialog.dart b/lib/fido/views/rename_fingerprint_dialog.dart index 023997ae..2c6c783c 100755 --- a/lib/fido/views/rename_fingerprint_dialog.dart +++ b/lib/fido/views/rename_fingerprint_dialog.dart @@ -112,7 +112,7 @@ class _RenameAccountDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/home/views/manage_label_dialog.dart b/lib/home/views/manage_label_dialog.dart index aa861fcb..76ff9536 100644 --- a/lib/home/views/manage_label_dialog.dart +++ b/lib/home/views/manage_label_dialog.dart @@ -87,7 +87,7 @@ class _ManageLabelDialogState extends ConsumerState { onFieldSubmitted: (_) { _submit(); }, - ) + ).init() ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index eb158dee..b7946768 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -233,6 +233,8 @@ "s_confirm_pin": "PIN bestätigen", "s_confirm_puk": null, "s_unblock_pin": null, + "l_pin_mismatch": null, + "l_puk_mismatch": null, "l_new_pin_len": "Neue PIN muss mindestens {length} Zeichen lang sein", "@l_new_pin_len": { "placeholders": { @@ -309,6 +311,7 @@ "s_new_password": "Neues Passwort", "s_current_password": "Aktuelles Passwort", "s_confirm_password": "Passwort bestätigen", + "l_password_mismatch": null, "s_wrong_password": "Falsches Passwort", "s_remove_password": "Passwort entfernen", "s_password_removed": "Passwort entfernt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 034ee62c..3d703e60 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -233,6 +233,8 @@ "s_confirm_pin": "Confirm PIN", "s_confirm_puk": "Confirm PUK", "s_unblock_pin": "Unblock PIN", + "l_pin_mismatch": "PINs do not match", + "l_puk_mismatch": "PUKs do not match", "l_new_pin_len": "New PIN must be at least {length} characters", "@l_new_pin_len": { "placeholders": { @@ -309,6 +311,7 @@ "s_new_password": "New password", "s_current_password": "Current password", "s_confirm_password": "Confirm password", + "l_password_mismatch": "Passwords do not match", "s_wrong_password": "Wrong password", "s_remove_password": "Remove password", "s_password_removed": "Password removed", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bcd182ca..f4f0b8b4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -233,6 +233,8 @@ "s_confirm_pin": "Confirmez le PIN", "s_confirm_puk": "Confirmez le PUK", "s_unblock_pin": "Débloquer le PIN", + "l_pin_mismatch": null, + "l_puk_mismatch": null, "l_new_pin_len": "Le nouveau PIN doit avoir au moins {length} caractères", "@l_new_pin_len": { "placeholders": { @@ -309,6 +311,7 @@ "s_new_password": "Nouveau mot de passe", "s_current_password": "Mot de passe actuel", "s_confirm_password": "Confirmez le mot de passe", + "l_password_mismatch": null, "s_wrong_password": "Mauvais mot de passe", "s_remove_password": "Supprimer le mot de passe", "s_password_removed": "Mot de passe supprimé", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index da406806..674c98d6 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -233,6 +233,8 @@ "s_confirm_pin": "PINの確認", "s_confirm_puk": "PUKの確認", "s_unblock_pin": "ブロックを解除", + "l_pin_mismatch": null, + "l_puk_mismatch": null, "l_new_pin_len": "新しいPINは少なくとも{length}文字である必要があります", "@l_new_pin_len": { "placeholders": { @@ -309,6 +311,7 @@ "s_new_password": "新しいパスワード", "s_current_password": "現在のパスワード", "s_confirm_password": "パスワードを確認", + "l_password_mismatch": null, "s_wrong_password": "間違ったパスワード", "s_remove_password": "パスワードの削除", "s_password_removed": "パスワードが削除されました", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 7ce3e7a0..80a34d33 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -233,6 +233,8 @@ "s_confirm_pin": "Potwierdź PIN", "s_confirm_puk": "Potwierdź PUK", "s_unblock_pin": "Odblokuj PIN", + "l_pin_mismatch": null, + "l_puk_mismatch": null, "l_new_pin_len": "Nowy PIN musi mieć co najmniej {length} znaków", "@l_new_pin_len": { "placeholders": { @@ -309,6 +311,7 @@ "s_new_password": "Nowe hasło", "s_current_password": "Aktualne hasło", "s_confirm_password": "Potwierdź hasło", + "l_password_mismatch": null, "s_wrong_password": "Błędne hasło", "s_remove_password": "Usuń hasło", "s_password_removed": "Hasło zostało usunięte", diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index 77540ed1..aca626c2 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -390,7 +390,7 @@ class _OathAddAccountPageState extends ConsumerState { onSubmitted: (_) { if (isValid) submit(); }, - ), + ).init(), AppTextField( key: keys.nameField, controller: _accountController, @@ -418,7 +418,7 @@ class _OathAddAccountPageState extends ConsumerState { onSubmitted: (_) { if (isValid) submit(); }, - ), + ).init(), AppTextField( key: keys.secretField, controller: _secretController, @@ -460,7 +460,7 @@ class _OathAddAccountPageState extends ConsumerState { onSubmitted: (_) { if (isValid) submit(); }, - ), + ).init(), const SizedBox(height: 8), Wrap( crossAxisAlignment: WrapCrossAlignment.center, diff --git a/lib/oath/views/manage_password_dialog.dart b/lib/oath/views/manage_password_dialog.dart index 907fec14..24a7c823 100755 --- a/lib/oath/views/manage_password_dialog.dart +++ b/lib/oath/views/manage_password_dialog.dart @@ -41,7 +41,8 @@ class ManagePasswordDialog extends ConsumerStatefulWidget { } class _ManagePasswordDialogState extends ConsumerState { - String _currentPassword = ''; + final _currentPasswordController = TextEditingController(); + final _currentPasswordFocus = FocusNode(); String _newPassword = ''; String _confirmPassword = ''; bool _currentIsWrong = false; @@ -49,12 +50,19 @@ class _ManagePasswordDialogState extends ConsumerState { bool _isObscureNew = true; bool _isObscureConfirm = true; + @override + void dispose() { + _currentPasswordController.dispose(); + _currentPasswordFocus.dispose(); + super.dispose(); + } + _submit() async { FocusUtils.unfocus(context); final result = await ref .read(oathStateProvider(widget.path).notifier) - .setPassword(_currentPassword, _newPassword); + .setPassword(_currentPasswordController.text, _newPassword); if (result) { if (mounted) { await ref.read(withContextProvider)((context) async { @@ -63,6 +71,9 @@ class _ManagePasswordDialogState extends ConsumerState { }); } } else { + _currentPasswordController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentPasswordController.text.length); + _currentPasswordFocus.requestFocus(); setState(() { _currentIsWrong = true; }); @@ -72,9 +83,10 @@ class _ManagePasswordDialogState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final isValid = _newPassword.isNotEmpty && + final isValid = !_currentIsWrong && + _newPassword.isNotEmpty && _newPassword == _confirmPassword && - (!widget.state.hasKey || _currentPassword.isNotEmpty); + (!widget.state.hasKey || _currentPasswordController.text.isNotEmpty); return ResponsiveDialog( title: Text( @@ -98,6 +110,8 @@ class _ManagePasswordDialogState extends ConsumerState { obscureText: _isObscureCurrent, autofillHints: const [AutofillHints.password], key: keys.currentPasswordField, + controller: _currentPasswordController, + focusNode: _currentPasswordFocus, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_current_password, @@ -121,21 +135,21 @@ class _ManagePasswordDialogState extends ConsumerState { onChanged: (value) { setState(() { _currentIsWrong = false; - _currentPassword = value; }); }, - ), + ).init(), Wrap( spacing: 4.0, runSpacing: 8.0, children: [ OutlinedButton( key: keys.removePasswordButton, - onPressed: _currentPassword.isNotEmpty + onPressed: _currentPasswordController.text.isNotEmpty && + !_currentIsWrong ? () async { final result = await ref .read(oathStateProvider(widget.path).notifier) - .unsetPassword(_currentPassword); + .unsetPassword(_currentPasswordController.text); if (result) { if (mounted) { await ref.read(withContextProvider)( @@ -145,6 +159,12 @@ class _ManagePasswordDialogState extends ConsumerState { }); } } else { + _currentPasswordController.selection = + TextSelection( + baseOffset: 0, + extentOffset: _currentPasswordController + .text.length); + _currentPasswordFocus.requestFocus(); setState(() { _currentIsWrong = true; }); @@ -193,7 +213,8 @@ class _ManagePasswordDialogState extends ConsumerState { tooltip: _isObscureNew ? l10n.s_show_password : l10n.s_hide_password), - enabled: !widget.state.hasKey || _currentPassword.isNotEmpty, + enabled: !widget.state.hasKey || + _currentPasswordController.text.isNotEmpty, ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -206,7 +227,7 @@ class _ManagePasswordDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), AppTextField( key: keys.confirmPasswordField, obscureText: _isObscureConfirm, @@ -227,9 +248,14 @@ class _ManagePasswordDialogState extends ConsumerState { tooltip: _isObscureConfirm ? l10n.s_show_password : l10n.s_hide_password), - enabled: - (!widget.state.hasKey || _currentPassword.isNotEmpty) && - _newPassword.isNotEmpty, + enabled: (!widget.state.hasKey || + _currentPasswordController.text.isNotEmpty) && + _newPassword.isNotEmpty, + errorText: _newPassword.length == _confirmPassword.length && + _newPassword != _confirmPassword + ? l10n.l_password_mismatch + : null, + helperText: '', // Prevents resizing when errorText shown ), textInputAction: TextInputAction.done, onChanged: (value) { @@ -242,7 +268,7 @@ class _ManagePasswordDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 946bd92a..2e634707 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -439,7 +439,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { Focus.of(context) .focusInDirection(TraversalDirection.down); }, - ), + ).init(), ); }), ), diff --git a/lib/oath/views/rename_account_dialog.dart b/lib/oath/views/rename_account_dialog.dart index 809870b6..bf6a26ce 100755 --- a/lib/oath/views/rename_account_dialog.dart +++ b/lib/oath/views/rename_account_dialog.dart @@ -193,7 +193,7 @@ class _RenameAccountDialogState extends ConsumerState { _issuer = value.trim(); }); }, - ), + ).init(), AppTextFormField( initialValue: _name, maxLength: nameRemaining, @@ -222,7 +222,7 @@ class _RenameAccountDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/oath/views/unlock_form.dart b/lib/oath/views/unlock_form.dart index a876fc64..92eddab1 100755 --- a/lib/oath/views/unlock_form.dart +++ b/lib/oath/views/unlock_form.dart @@ -38,6 +38,7 @@ class UnlockForm extends ConsumerStatefulWidget { class _UnlockFormState extends ConsumerState { final _passwordController = TextEditingController(); + final _passwordFocus = FocusNode(); bool _remember = false; bool _passwordIsWrong = false; bool _isObscure = true; @@ -51,9 +52,11 @@ class _UnlockFormState extends ConsumerState { .unlock(_passwordController.text, remember: _remember); if (!mounted) return; if (!success) { + _passwordController.selection = TextSelection( + baseOffset: 0, extentOffset: _passwordController.text.length); + _passwordFocus.requestFocus(); setState(() { _passwordIsWrong = true; - _passwordController.clear(); }); } else if (_remember && !remembered) { showMessage(context, AppLocalizations.of(context)!.l_remember_pw_failed); @@ -79,6 +82,7 @@ class _UnlockFormState extends ConsumerState { child: AppTextField( key: keys.passwordField, controller: _passwordController, + focusNode: _passwordFocus, autofocus: true, obscureText: _isObscure, autofillHints: const [AutofillHints.password], @@ -106,7 +110,7 @@ class _UnlockFormState extends ConsumerState { _passwordIsWrong = false; }), // Update state on change onSubmitted: (_) => _submit(), - ), + ).init(), ), const SizedBox(height: 3.0), Column( @@ -143,7 +147,8 @@ class _UnlockFormState extends ConsumerState { key: keys.unlockButton, label: Text(l10n.s_unlock), icon: const Icon(Symbols.lock_open), - onPressed: _passwordController.text.isNotEmpty + onPressed: _passwordController.text.isNotEmpty && + !_passwordIsWrong ? _submit : null, ), diff --git a/lib/otp/views/configure_chalresp_dialog.dart b/lib/otp/views/configure_chalresp_dialog.dart index c2d5bdde..2170d54e 100644 --- a/lib/otp/views/configure_chalresp_dialog.dart +++ b/lib/otp/views/configure_chalresp_dialog.dart @@ -166,7 +166,7 @@ class _ConfigureChalrespDialogState _validateSecret = false; }); }, - ), + ).init(), FilterChip( label: Text(l10n.s_require_touch), selected: _requireTouch, diff --git a/lib/otp/views/configure_hotp_dialog.dart b/lib/otp/views/configure_hotp_dialog.dart index c5cf7db8..3c7b4910 100644 --- a/lib/otp/views/configure_hotp_dialog.dart +++ b/lib/otp/views/configure_hotp_dialog.dart @@ -127,6 +127,7 @@ class _ConfigureHotpDialogState extends ConsumerState { key: keys.secretField, controller: _secretController, obscureText: _isObscure, + autofocus: true, autofillHints: isAndroid ? [] : const [AutofillHints.password], decoration: AppInputDecoration( border: const OutlineInputBorder(), @@ -158,7 +159,7 @@ class _ConfigureHotpDialogState extends ConsumerState { _validateSecret = false; }); }, - ), + ).init(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, diff --git a/lib/otp/views/configure_static_dialog.dart b/lib/otp/views/configure_static_dialog.dart index 931a5df3..2a1491dd 100644 --- a/lib/otp/views/configure_static_dialog.dart +++ b/lib/otp/views/configure_static_dialog.dart @@ -181,7 +181,7 @@ class _ConfigureStaticDialogState extends ConsumerState { _validatePassword = false; }); }, - ), + ).init(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, diff --git a/lib/otp/views/configure_yubiotp_dialog.dart b/lib/otp/views/configure_yubiotp_dialog.dart index 0cfa9c84..81643834 100644 --- a/lib/otp/views/configure_yubiotp_dialog.dart +++ b/lib/otp/views/configure_yubiotp_dialog.dart @@ -237,7 +237,7 @@ class _ConfigureYubiOtpDialogState _validatePublicIdFormat = false; }); }, - ), + ).init(), AppTextField( key: keys.privateIdField, controller: _privateIdController, @@ -274,7 +274,7 @@ class _ConfigureYubiOtpDialogState _validatePrivateIdFormat = false; }); }, - ), + ).init(), AppTextField( key: keys.secretField, controller: _secretController, @@ -311,7 +311,7 @@ class _ConfigureYubiOtpDialogState _validateSecretFormat = false; }); }, - ), + ).init(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, diff --git a/lib/piv/state.dart b/lib/piv/state.dart index ec3361b5..4a4b6771 100644 --- a/lib/piv/state.dart +++ b/lib/piv/state.dart @@ -35,8 +35,7 @@ abstract class PivStateNotifier extends ApplicationStateNotifier { bool storeKey = false, }); - Future verifyPin( - String pin); //TODO: Maybe return authenticated? + Future verifyPin(String pin); Future changePin(String pin, String newPin); Future changePuk(String puk, String newPuk); Future unblockPin(String puk, String newPin); diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 691bbe98..5790f774 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -52,24 +52,26 @@ class ExportIntent extends Intent { const ExportIntent(this.slot); } -Future _authenticate( - BuildContext context, DevicePath devicePath, PivState pivState) async { - return await showBlurDialog( - context: context, - builder: (context) => pivState.protectedKey - ? PinDialog(devicePath) - : AuthenticationDialog( - devicePath, - pivState, - ), - ) ?? - false; -} - -Future _authIfNeeded( - BuildContext context, DevicePath devicePath, PivState pivState) async { +Future _authIfNeeded(BuildContext context, WidgetRef ref, + DevicePath devicePath, PivState pivState) async { if (pivState.needsAuth) { - return await _authenticate(context, devicePath, pivState); + if (pivState.protectedKey && + pivState.metadata?.pinMetadata.defaultValue == true) { + final status = await ref + .read(pivStateProvider(devicePath).notifier) + .verifyPin(defaultPin); + return status.when(success: () => true, failure: (_) => false); + } + return await showBlurDialog( + context: context, + builder: (context) => pivState.protectedKey + ? PinDialog(devicePath) + : AuthenticationDialog( + devicePath, + pivState, + ), + ) ?? + false; } return true; } @@ -96,21 +98,32 @@ class PivActions extends ConsumerWidget { if (hasFeature(features.slotsGenerate)) GenerateIntent: CallbackAction(onInvoke: (intent) async { - if (!pivState.protectedKey && - !await withContext((context) => - _authIfNeeded(context, devicePath, pivState))) { + //Verify management key and maybe PIN + if (!await withContext((context) => + _authIfNeeded(context, ref, devicePath, pivState))) { return false; } - + // Verify PIN, unless already done above // TODO: Avoid asking for PIN if not needed? - final verified = await withContext((context) async => - await showBlurDialog( - context: context, - builder: (context) => PinDialog(devicePath))) ?? - false; + if (!pivState.protectedKey) { + bool verified; + if (pivState.metadata?.pinMetadata.defaultValue == true) { + final status = await ref + .read(pivStateProvider(devicePath).notifier) + .verifyPin(defaultPin); + verified = + status.when(success: () => true, failure: (_) => false); + } else { + verified = await withContext((context) async => + await showBlurDialog( + context: context, + builder: (context) => PinDialog(devicePath))) ?? + false; + } - if (!verified) { - return false; + if (!verified) { + return false; + } } return await withContext((context) async { @@ -158,8 +171,8 @@ class PivActions extends ConsumerWidget { }), if (hasFeature(features.slotsImport)) ImportIntent: CallbackAction(onInvoke: (intent) async { - if (!await withContext( - (context) => _authIfNeeded(context, devicePath, pivState))) { + if (!await withContext((context) => + _authIfNeeded(context, ref, devicePath, pivState))) { return false; } @@ -243,8 +256,8 @@ class PivActions extends ConsumerWidget { if (hasFeature(features.slotsDelete)) DeleteIntent: CallbackAction>(onInvoke: (intent) async { - if (!await withContext( - (context) => _authIfNeeded(context, devicePath, pivState))) { + if (!await withContext((context) => + _authIfNeeded(context, ref, devicePath, pivState))) { return false; } diff --git a/lib/piv/views/authentication_dialog.dart b/lib/piv/views/authentication_dialog.dart index cc415246..3f2ed339 100644 --- a/lib/piv/views/authentication_dialog.dart +++ b/lib/piv/views/authentication_dialog.dart @@ -44,10 +44,12 @@ class _AuthenticationDialogState extends ConsumerState { bool _keyIsWrong = false; bool _keyFormatInvalid = false; final _keyController = TextEditingController(); + final _keyFocus = FocusNode(); @override void dispose() { _keyController.dispose(); + _keyFocus.dispose(); super.dispose(); } @@ -65,7 +67,7 @@ class _AuthenticationDialogState extends ConsumerState { actions: [ TextButton( key: keys.unlockButton, - onPressed: _keyController.text.length == keyLen + onPressed: !_keyIsWrong && _keyController.text.length == keyLen ? () async { if (keyFormatInvalid) { setState(() { @@ -81,6 +83,10 @@ class _AuthenticationDialogState extends ConsumerState { if (status) { navigator.pop(true); } else { + _keyController.selection = TextSelection( + baseOffset: 0, + extentOffset: _keyController.text.length); + _keyFocus.requestFocus(); setState(() { _keyIsWrong = true; }); @@ -88,6 +94,10 @@ class _AuthenticationDialogState extends ConsumerState { } on CancellationException catch (_) { navigator.pop(false); } catch (_) { + _keyController.selection = TextSelection( + baseOffset: 0, + extentOffset: _keyController.text.length); + _keyFocus.requestFocus(); // TODO: More error cases setState(() { _keyIsWrong = true; @@ -109,6 +119,7 @@ class _AuthenticationDialogState extends ConsumerState { autofocus: true, autofillHints: const [AutofillHints.password], controller: _keyController, + focusNode: _keyFocus, readOnly: _defaultKeyUsed, maxLength: !_defaultKeyUsed ? keyLen : null, decoration: AppInputDecoration( @@ -149,7 +160,7 @@ class _AuthenticationDialogState extends ConsumerState { _keyFormatInvalid = false; }); }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/piv/views/generate_key_dialog.dart b/lib/piv/views/generate_key_dialog.dart index f4451452..11d85f04 100644 --- a/lib/piv/views/generate_key_dialog.dart +++ b/lib/piv/views/generate_key_dialog.dart @@ -174,7 +174,7 @@ class _GenerateKeyDialogState extends ConsumerState { _subject = value; }); }, - ), + ).init(), Text( l10n.rfc4514_examples, style: subtitleStyle, diff --git a/lib/piv/views/import_file_dialog.dart b/lib/piv/views/import_file_dialog.dart index 4c2327a8..f65a3e51 100644 --- a/lib/piv/views/import_file_dialog.dart +++ b/lib/piv/views/import_file_dialog.dart @@ -162,7 +162,7 @@ class _ImportFileDialogState extends ConsumerState { }); }, onSubmitted: (_) => _examine(), - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/piv/views/key_actions.dart b/lib/piv/views/key_actions.dart index b4c1119e..d6916a21 100644 --- a/lib/piv/views/key_actions.dart +++ b/lib/piv/views/key_actions.dart @@ -60,7 +60,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, ActionListItem( key: keys.managePinAction, feature: features.actionsPin, - title: l10n.s_pin, + title: l10n.s_change_pin, subtitle: pinBlocked ? (pukAttempts != 0 ? l10n.l_piv_pin_blocked @@ -88,7 +88,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, ActionListItem( key: keys.managePukAction, feature: features.actionsPuk, - title: l10n.s_puk, + title: l10n.s_change_puk, subtitle: pukAttempts != null ? (pukAttempts == 0 ? l10n.l_piv_pin_puk_blocked diff --git a/lib/piv/views/manage_key_dialog.dart b/lib/piv/views/manage_key_dialog.dart index f2ded3b6..700c28a9 100644 --- a/lib/piv/views/manage_key_dialog.dart +++ b/lib/piv/views/manage_key_dialog.dart @@ -30,6 +30,7 @@ import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_form_field.dart'; import '../../widgets/choice_filter_chip.dart'; import '../../widgets/responsive_dialog.dart'; +import '../../widgets/utf8_utils.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; @@ -48,6 +49,7 @@ class ManageKeyDialog extends ConsumerStatefulWidget { class _ManageKeyDialogState extends ConsumerState { late bool _hasMetadata; late bool _defaultKeyUsed; + late bool _defaultPinUsed; late bool _usesStoredKey; late bool _storeKey; bool _currentIsWrong = false; @@ -56,6 +58,7 @@ class _ManageKeyDialogState extends ConsumerState { int _attemptsRemaining = -1; late ManagementKeyType _keyType; final _currentController = TextEditingController(); + final _currentFocus = FocusNode(); final _keyController = TextEditingController(); bool _isObscure = true; @@ -68,9 +71,13 @@ class _ManageKeyDialogState extends ConsumerState { defaultManagementKeyType; _defaultKeyUsed = widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false; + _defaultPinUsed = + widget.pivState.metadata?.pinMetadata.defaultValue ?? false; _usesStoredKey = widget.pivState.protectedKey; if (!_usesStoredKey && _defaultKeyUsed) { _currentController.text = defaultManagementKey; + } else if (_usesStoredKey && _defaultPinUsed) { + _currentController.text = defaultPin; } _storeKey = _usesStoredKey; } @@ -79,16 +86,18 @@ class _ManageKeyDialogState extends ConsumerState { void dispose() { _keyController.dispose(); _currentController.dispose(); + _currentFocus.dispose(); super.dispose(); } _submit() async { - final currentInvalidFormat = Format.hex.isValid(_currentController.text); - final newInvalidFormat = Format.hex.isValid(_keyController.text); - if (!currentInvalidFormat || !newInvalidFormat) { + final currentValidFormat = + _usesStoredKey || Format.hex.isValid(_currentController.text); + final newValidFormat = Format.hex.isValid(_keyController.text); + if (!currentValidFormat || !newValidFormat) { setState(() { - _currentInvalidFormat = !currentInvalidFormat; - _newInvalidFormat = !newInvalidFormat; + _currentInvalidFormat = !currentValidFormat; + _newInvalidFormat = !newValidFormat; }); return; } @@ -98,6 +107,9 @@ class _ManageKeyDialogState extends ConsumerState { final status = (await notifier.verifyPin(_currentController.text)).when( success: () => true, failure: (attemptsRemaining) { + _currentController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentController.text.length); + _currentFocus.requestFocus(); setState(() { _attemptsRemaining = attemptsRemaining; _currentIsWrong = true; @@ -110,6 +122,9 @@ class _ManageKeyDialogState extends ConsumerState { } } else { if (!await notifier.authenticate(_currentController.text)) { + _currentController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentController.text.length); + _currentFocus.requestFocus(); setState(() { _currentIsWrong = true; }); @@ -118,15 +133,19 @@ class _ManageKeyDialogState extends ConsumerState { } if (_storeKey && !_usesStoredKey) { - final withContext = ref.read(withContextProvider); - final verified = await withContext((context) async => - await showBlurDialog( - context: context, - builder: (context) => PinDialog(widget.path))) ?? - false; + if (_defaultPinUsed) { + await notifier.verifyPin(defaultPin); + } else { + final withContext = ref.read(withContextProvider); + final verified = await withContext((context) async => + await showBlurDialog( + context: context, + builder: (context) => PinDialog(widget.path))) ?? + false; - if (!verified) { - return; + if (!verified) { + return; + } } } @@ -147,9 +166,8 @@ class _ManageKeyDialogState extends ConsumerState { widget.pivState.metadata?.managementKeyMetadata.keyType ?? defaultManagementKeyType; final hexLength = _keyType.keyLength * 2; - final protected = widget.pivState.protectedKey; final currentKeyOrPin = _currentController.text; - final currentLenOk = protected + final currentLenOk = _usesStoredKey ? currentKeyOrPin.length >= 4 : currentKeyOrPin.length == currentType.keyLength * 2; final newLenOk = _keyController.text.length == hexLength; @@ -158,7 +176,8 @@ class _ManageKeyDialogState extends ConsumerState { title: Text(l10n.l_change_management_key), actions: [ TextButton( - onPressed: currentLenOk && newLenOk ? _submit : null, + onPressed: + !_currentIsWrong && currentLenOk && newLenOk ? _submit : null, key: keys.saveButton, child: Text(l10n.s_save), ) @@ -169,23 +188,25 @@ class _ManageKeyDialogState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.p_change_management_key_desc), - if (protected) + if (_usesStoredKey) AppTextField( autofocus: true, obscureText: _isObscure, autofillHints: const [AutofillHints.password], key: keys.pinPukField, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(_currentController.text), controller: _currentController, + focusNode: _currentFocus, + readOnly: _defaultPinUsed, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, + helperText: _defaultPinUsed ? l10n.l_default_pin_used : null, errorText: _currentIsWrong ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining) - : _currentInvalidFormat - ? l10n.l_invalid_format_allowed_chars( - Format.hex.allowedCharacters) - : null, + : null, errorMaxLines: 3, prefixIcon: const Icon(Symbols.pin), suffixIcon: IconButton( @@ -206,13 +227,14 @@ class _ManageKeyDialogState extends ConsumerState { _currentInvalidFormat = false; }); }, - ), - if (!protected) + ).init(), + if (!_usesStoredKey) AppTextFormField( key: keys.managementKeyField, autofocus: !_defaultKeyUsed, autofillHints: const [AutofillHints.password], controller: _currentController, + focusNode: _currentFocus, readOnly: _defaultKeyUsed, maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null, decoration: AppInputDecoration( @@ -251,7 +273,7 @@ class _ManageKeyDialogState extends ConsumerState { _currentIsWrong = false; }); }, - ), + ).init(), AppTextField( key: keys.newPinPukField, autofocus: _defaultKeyUsed, @@ -299,7 +321,7 @@ class _ManageKeyDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, diff --git a/lib/piv/views/manage_pin_puk_dialog.dart b/lib/piv/views/manage_pin_puk_dialog.dart index 63bd889f..a874751c 100644 --- a/lib/piv/views/manage_pin_puk_dialog.dart +++ b/lib/piv/views/manage_pin_puk_dialog.dart @@ -24,6 +24,7 @@ import '../../app/models.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/responsive_dialog.dart'; +import '../../widgets/utf8_utils.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; @@ -44,20 +45,25 @@ class ManagePinPukDialog extends ConsumerStatefulWidget { class _ManagePinPukDialogState extends ConsumerState { final _currentPinController = TextEditingController(); + final _currentPinFocus = FocusNode(); String _newPin = ''; String _confirmPin = ''; + bool _pinIsBlocked = false; bool _currentIsWrong = false; int _attemptsRemaining = -1; bool _isObscureCurrent = true; bool _isObscureNew = true; bool _isObscureConfirm = true; - late bool _defaultPinUsed; - late bool _defaultPukUsed; + late final bool _defaultPinUsed; + late final bool _defaultPukUsed; + late final int _minPinLen; @override void initState() { super.initState(); + // Old YubiKeys allowed a 4 digit PIN + _minPinLen = widget.pivState.version.isAtLeast(4, 3, 1) ? 6 : 4; _defaultPinUsed = widget.pivState.metadata?.pinMetadata.defaultValue ?? false; _defaultPukUsed = @@ -73,6 +79,7 @@ class _ManagePinPukDialogState extends ConsumerState { @override void dispose() { _currentPinController.dispose(); + _currentPinFocus.dispose(); super.dispose(); } @@ -98,11 +105,16 @@ class _ManagePinPukDialogState extends ConsumerState { _ => l10n.s_pin_set, }); }, failure: (attemptsRemaining) { + _currentPinController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentPinController.text.length); + _currentPinFocus.requestFocus(); setState(() { _attemptsRemaining = attemptsRemaining; _currentIsWrong = true; + if (_attemptsRemaining == 0) { + _pinIsBlocked = true; + } }); - _currentPinController.clear(); }); } @@ -110,8 +122,12 @@ class _ManagePinPukDialogState extends ConsumerState { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final currentPin = _currentPinController.text; - final isValid = - _newPin.isNotEmpty && _newPin == _confirmPin && currentPin.isNotEmpty; + final currentPinLen = byteLength(currentPin); + final newPinLen = byteLength(_newPin); + final isValid = !_currentIsWrong && + _newPin.isNotEmpty && + _newPin == _confirmPin && + currentPin.isNotEmpty; final titleText = switch (widget.target) { ManageTarget.pin => l10n.s_change_pin, @@ -138,7 +154,6 @@ class _ManagePinPukDialogState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - //TODO fix string Text(widget.target == ManageTarget.pin ? l10n.p_enter_current_pin_or_reset : l10n.p_enter_current_puk_or_reset), @@ -146,10 +161,14 @@ class _ManagePinPukDialogState extends ConsumerState { autofocus: !(showDefaultPinUsed || showDefaultPukUsed), obscureText: _isObscureCurrent, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(currentPin), autofillHints: const [AutofillHints.password], key: keys.pinPukField, readOnly: showDefaultPinUsed || showDefaultPukUsed, controller: _currentPinController, + focusNode: _currentPinFocus, + enabled: !_pinIsBlocked, decoration: AppInputDecoration( border: const OutlineInputBorder(), helperText: showDefaultPinUsed @@ -160,13 +179,17 @@ class _ManagePinPukDialogState extends ConsumerState { labelText: widget.target == ManageTarget.pin ? l10n.s_current_pin : l10n.s_current_puk, - errorText: _currentIsWrong + errorText: _pinIsBlocked ? (widget.target == ManageTarget.pin - ? l10n - .l_wrong_pin_attempts_remaining(_attemptsRemaining) - : l10n - .l_wrong_puk_attempts_remaining(_attemptsRemaining)) - : null, + ? l10n.l_piv_pin_blocked + : l10n.l_piv_pin_puk_blocked) + : (_currentIsWrong + ? (widget.target == ManageTarget.pin + ? l10n.l_wrong_pin_attempts_remaining( + _attemptsRemaining) + : l10n.l_wrong_puk_attempts_remaining( + _attemptsRemaining)) + : null), errorMaxLines: 3, prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( @@ -189,7 +212,7 @@ class _ManagePinPukDialogState extends ConsumerState { _currentIsWrong = false; }); }, - ), + ).init(), Text(l10n.p_enter_new_piv_pin_puk( widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)), AppTextField( @@ -197,6 +220,8 @@ class _ManagePinPukDialogState extends ConsumerState { autofocus: showDefaultPinUsed || showDefaultPukUsed, obscureText: _isObscureNew, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(_newPin), autofillHints: const [AutofillHints.newPassword], decoration: AppInputDecoration( border: const OutlineInputBorder(), @@ -217,8 +242,7 @@ class _ManagePinPukDialogState extends ConsumerState { ? (_isObscureNew ? l10n.s_show_pin : l10n.s_hide_pin) : (_isObscureNew ? l10n.s_show_puk : l10n.s_hide_puk), ), - // Old YubiKeys allowed a 4 digit PIN - enabled: currentPin.length >= 4, + enabled: currentPinLen >= _minPinLen, ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -231,11 +255,13 @@ class _ManagePinPukDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), AppTextField( key: keys.confirmPinPukField, obscureText: _isObscureConfirm, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(_confirmPin), autofillHints: const [AutofillHints.newPassword], decoration: AppInputDecoration( border: const OutlineInputBorder(), @@ -256,7 +282,14 @@ class _ManagePinPukDialogState extends ConsumerState { ? (_isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin) : (_isObscureConfirm ? l10n.s_show_puk : l10n.s_hide_puk), ), - enabled: currentPin.length >= 4 && _newPin.length >= 6, + enabled: currentPinLen >= _minPinLen && newPinLen >= 6, + errorText: + newPinLen == _confirmPin.length && _newPin != _confirmPin + ? (widget.target == ManageTarget.pin + ? l10n.l_pin_mismatch + : l10n.l_puk_mismatch) + : null, + helperText: '', // Prevents resizing when errorText shown ), textInputAction: TextInputAction.done, onChanged: (value) { @@ -269,7 +302,7 @@ class _ManagePinPukDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/piv/views/pin_dialog.dart b/lib/piv/views/pin_dialog.dart index 66106cab..fab45816 100644 --- a/lib/piv/views/pin_dialog.dart +++ b/lib/piv/views/pin_dialog.dart @@ -24,6 +24,7 @@ import '../../exception/cancellation_exception.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/responsive_dialog.dart'; +import '../../widgets/utf8_utils.dart'; import '../keys.dart' as keys; import '../state.dart'; @@ -37,6 +38,7 @@ class PinDialog extends ConsumerStatefulWidget { class _PinDialogState extends ConsumerState { final _pinController = TextEditingController(); + final _pinFocus = FocusNode(); bool _pinIsWrong = false; int _attemptsRemaining = -1; bool _isObscure = true; @@ -44,6 +46,7 @@ class _PinDialogState extends ConsumerState { @override void dispose() { _pinController.dispose(); + _pinFocus.dispose(); super.dispose(); } @@ -58,8 +61,10 @@ class _PinDialogState extends ConsumerState { navigator.pop(true); }, failure: (attemptsRemaining) { + _pinController.selection = TextSelection( + baseOffset: 0, extentOffset: _pinController.text.length); + _pinFocus.requestFocus(); setState(() { - _pinController.clear(); _attemptsRemaining = attemptsRemaining; _pinIsWrong = true; }); @@ -73,12 +78,15 @@ class _PinDialogState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final version = ref.watch(pivStateProvider(widget.devicePath)).valueOrNull; + final minPinLen = version?.version.isAtLeast(4, 3, 1) == true ? 6 : 4; + final currentPinLen = byteLength(_pinController.text); return ResponsiveDialog( title: Text(l10n.s_pin_required), actions: [ TextButton( key: keys.unlockButton, - onPressed: _pinController.text.length >= 4 ? _submit : null, + onPressed: currentPinLen >= minPinLen ? _submit : null, child: Text(l10n.s_unlock), ), ], @@ -92,9 +100,12 @@ class _PinDialogState extends ConsumerState { autofocus: true, obscureText: _isObscure, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(_pinController.text), autofillHints: const [AutofillHints.password], key: keys.managementKeyField, controller: _pinController, + focusNode: _pinFocus, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, @@ -121,7 +132,7 @@ class _PinDialogState extends ConsumerState { }); }, onSubmitted: (_) => _submit(), - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/widgets/app_text_field.dart b/lib/widgets/app_text_field.dart index ff0c49e3..4355083f 100644 --- a/lib/widgets/app_text_field.dart +++ b/lib/widgets/app_text_field.dart @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import 'package:flutter/material.dart'; import 'app_input_decoration.dart'; @@ -86,4 +85,13 @@ class AppTextField extends TextField { super.spellCheckConfiguration, super.magnifierConfiguration, }) : super(decoration: decoration); + + Widget init() => Builder( + builder: (context) => DefaultSelectionStyle( + selectionColor: decoration?.errorText != null + ? Theme.of(context).colorScheme.error + : null, + child: this, + ), + ); } diff --git a/lib/widgets/app_text_form_field.dart b/lib/widgets/app_text_form_field.dart index 61e0540b..5736b21e 100644 --- a/lib/widgets/app_text_form_field.dart +++ b/lib/widgets/app_text_form_field.dart @@ -20,6 +20,7 @@ import 'app_input_decoration.dart'; /// TextFormField without autocorrect and suggestions class AppTextFormField extends TextFormField { + final AppInputDecoration? decoration; AppTextFormField({ // default settings to turn off autocorrect super.autocorrect = false, @@ -30,7 +31,7 @@ class AppTextFormField extends TextFormField { super.controller, super.initialValue, super.focusNode, - AppInputDecoration? decoration, + this.decoration, super.textCapitalization, super.textInputAction, super.style, @@ -90,4 +91,13 @@ class AppTextFormField extends TextFormField { super.scribbleEnabled, super.canRequestFocus, }) : super(decoration: decoration); + + Widget init() => Builder( + builder: (context) => DefaultSelectionStyle( + selectionColor: decoration?.errorText != null + ? Theme.of(context).colorScheme.error + : null, + child: this, + ), + ); } diff --git a/lint/lib/lint.dart b/lint/lib/lint.dart index 4f871b98..c3ffc036 100644 --- a/lint/lib/lint.dart +++ b/lint/lib/lint.dart @@ -14,6 +14,7 @@ * limitations under the License. */ +import 'package:analyzer/dart/ast/token.dart'; import 'package:analyzer/error/listener.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart'; @@ -30,6 +31,8 @@ class _AppLinter extends PluginBase { discouraged: 'TextFormField', recommended: 'AppTextFormField', ), + const CallInitAfterCreation(className: 'AppTextField'), + const CallInitAfterCreation(className: 'AppTextFormField'), ]; } @@ -59,3 +62,35 @@ class UseRecommendedWidget extends DartLintRule { }); } } + +class CallInitAfterCreation extends DartLintRule { + final String className; + + const CallInitAfterCreation({required this.className}) + : super( + code: const LintCode( + name: 'call_init_after_creation', + problemMessage: 'Call init() after creation', + )); + + @override + void run( + CustomLintResolver resolver, + ErrorReporter reporter, + CustomLintContext context, + ) { + context.registry.addInstanceCreationExpression((node) { + if (node.constructorName.toString() == className) { + final dot = node.endToken.next; + final next = dot?.next; + if (dot?.type == TokenType.PERIOD) { + if (next?.type == TokenType.IDENTIFIER && + next?.toString() == 'init') { + return; + } + } + reporter.reportErrorForNode(code, node.constructorName); + } + }); + } +}