diff --git a/lib/app/views/action_list.dart b/lib/app/views/action_list.dart index 9e8306847..13b621725 100644 --- a/lib/app/views/action_list.dart +++ b/lib/app/views/action_list.dart @@ -54,31 +54,40 @@ class ActionListItem extends StatelessWidget { // }; final theme = Theme.of(context); - return ListTile( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)), - title: TooltipIfTruncated( - text: title, - style: TextStyle(fontSize: theme.textTheme.bodyLarge!.fontSize), - ), - subtitle: subtitle != null - ? TooltipIfTruncated( - text: subtitle!, - style: TextStyle(fontSize: theme.textTheme.bodyMedium!.fontSize), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ) + return GestureDetector( + onTap: onTap == null + ? () { + // Needed to avoid triggering escape intent when tapping + // on a disabled item + } : null, - leading: Opacity( - opacity: onTap != null ? 1.0 : 0.4, - child: CircleAvatar( - foregroundColor: theme.colorScheme.onSurfaceVariant, - backgroundColor: Colors.transparent, - child: icon, + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)), + title: TooltipIfTruncated( + text: title, + style: TextStyle(fontSize: theme.textTheme.bodyLarge!.fontSize), + ), + subtitle: subtitle != null + ? TooltipIfTruncated( + text: subtitle!, + style: + TextStyle(fontSize: theme.textTheme.bodyMedium!.fontSize), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : null, + leading: Opacity( + opacity: onTap != null ? 1.0 : 0.4, + child: CircleAvatar( + foregroundColor: theme.colorScheme.onSurfaceVariant, + backgroundColor: Colors.transparent, + child: icon, + ), ), + trailing: trailing, + onTap: onTap != null ? () => onTap?.call(context) : null, + enabled: onTap != null, ), - trailing: trailing, - onTap: onTap != null ? () => onTap?.call(context) : null, - enabled: onTap != null, ); } } diff --git a/lib/fido/views/pin_entry_form.dart b/lib/fido/views/pin_entry_form.dart index b541a48ef..ac36973d4 100644 --- a/lib/fido/views/pin_entry_form.dart +++ b/lib/fido/views/pin_entry_form.dart @@ -150,7 +150,13 @@ class _PinEntryFormState extends ConsumerState { _pinIsWrong = false; }); }, // Update state on change - onSubmitted: (_) => _submit(), + onSubmitted: (_) { + if (_pinController.text.length >= widget._state.minPinLength) { + _submit(); + } else { + _pinFocus.requestFocus(); + } + }, ).init(), ), ListTile( diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 3498d6005..6ec762413 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -70,7 +70,7 @@ Future _authIfNeeded(BuildContext context, WidgetRef ref, return await showBlurDialog( context: context, builder: (context) => pivState.protectedKey - ? PinDialog(devicePath) + ? PinDialog(devicePath, pivState) : AuthenticationDialog( devicePath, pivState, @@ -120,7 +120,8 @@ class PivActions extends ConsumerWidget { verified = await withContext((context) async => await showBlurDialog( context: context, - builder: (context) => PinDialog(devicePath))) ?? + builder: (context) => + PinDialog(devicePath, pivState))) ?? false; } @@ -333,6 +334,10 @@ List buildSlotActions( final hasCert = slot.certInfo != null; final hasKey = slot.metadata != null; final canDeleteOrMoveKey = hasKey && pivState.version.isAtLeast(5, 7); + final pinIsBlocked = pivState.pinAttempts == 0; + final defaultPin = pivState.metadata?.pinMetadata.defaultValue == true; + final requiresPinAuth = + pivState.needsAuth && pivState.protectedKey && !defaultPin; return [ if (!slot.slot.isRetired) ...[ ActionItem( @@ -342,7 +347,10 @@ List buildSlotActions( actionStyle: ActionStyle.primary, title: l10n.s_generate_key, subtitle: l10n.l_generate_desc, - intent: GenerateIntent(slot), + intent: (pinIsBlocked && + (requiresPinAuth || (!pivState.protectedKey && !defaultPin))) + ? null + : GenerateIntent(slot), ), ActionItem( key: keys.importAction, @@ -350,7 +358,7 @@ List buildSlotActions( icon: const Icon(Symbols.file_download), title: l10n.l_import_file, subtitle: l10n.l_import_desc, - intent: ImportIntent(slot), + intent: pinIsBlocked && requiresPinAuth ? null : ImportIntent(slot), ), ], if (hasCert) ...[ @@ -380,7 +388,7 @@ List buildSlotActions( icon: const Icon(Symbols.move_item), title: l10n.l_move_key, subtitle: l10n.l_move_key_desc, - intent: MoveIntent(slot), + intent: pinIsBlocked && requiresPinAuth ? null : MoveIntent(slot), ), if (hasCert || canDeleteOrMoveKey) ActionItem( @@ -398,7 +406,7 @@ List buildSlotActions( : hasCert ? l10n.l_delete_certificate_desc : l10n.l_delete_key_desc, - intent: DeleteIntent(slot), + intent: pinIsBlocked && requiresPinAuth ? null : DeleteIntent(slot), ), ]; } diff --git a/lib/piv/views/manage_key_dialog.dart b/lib/piv/views/manage_key_dialog.dart index 6283a90c3..ae5bcaef3 100644 --- a/lib/piv/views/manage_key_dialog.dart +++ b/lib/piv/views/manage_key_dialog.dart @@ -146,7 +146,8 @@ class _ManageKeyDialogState extends ConsumerState { final verified = await withContext((context) async => await showBlurDialog( context: context, - builder: (context) => PinDialog(widget.path))) ?? + builder: (context) => + PinDialog(widget.path, widget.pivState))) ?? false; if (!verified) { diff --git a/lib/piv/views/pin_dialog.dart b/lib/piv/views/pin_dialog.dart index 8432433c4..2b0eaaabc 100644 --- a/lib/piv/views/pin_dialog.dart +++ b/lib/piv/views/pin_dialog.dart @@ -26,11 +26,13 @@ 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'; class PinDialog extends ConsumerStatefulWidget { final DevicePath devicePath; - const PinDialog(this.devicePath, {super.key}); + final PivState pivState; + const PinDialog(this.devicePath, this.pivState, {super.key}); @override ConsumerState createState() => _PinDialogState(); @@ -41,8 +43,15 @@ class _PinDialogState extends ConsumerState { final _pinFocus = FocusNode(); bool _pinIsWrong = false; int _attemptsRemaining = -1; + late bool _pinIsBlocked; bool _isObscure = true; + @override + void initState() { + super.initState(); + _pinIsBlocked = widget.pivState.pinAttempts == 0; + } + @override void dispose() { _pinController.dispose(); @@ -69,6 +78,9 @@ class _PinDialogState extends ConsumerState { setState(() { _attemptsRemaining = attemptsRemaining; _pinIsWrong = true; + if (_attemptsRemaining == 0) { + _pinIsBlocked = true; + } }); }, orElse: () {}, @@ -83,15 +95,16 @@ 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 minPinLen = + widget.pivState.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: currentPinLen >= minPinLen ? _submit : null, + onPressed: + currentPinLen >= minPinLen && !_pinIsBlocked ? _submit : null, child: Text(l10n.s_unlock), ), ], @@ -111,12 +124,16 @@ class _PinDialogState extends ConsumerState { key: keys.managementKeyField, controller: _pinController, focusNode: _pinFocus, + enabled: !_pinIsBlocked, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, - errorText: _pinIsWrong - ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining) - : null, + errorText: _pinIsBlocked + ? l10n.l_piv_pin_blocked + : _pinIsWrong + ? l10n + .l_wrong_pin_attempts_remaining(_attemptsRemaining) + : null, errorMaxLines: 3, icon: const Icon(Symbols.pin), suffixIcon: IconButton( @@ -136,7 +153,13 @@ class _PinDialogState extends ConsumerState { _pinIsWrong = false; }); }, - onSubmitted: (_) => _submit(), + onSubmitted: (_) { + if (currentPinLen >= minPinLen) { + _submit(); + } else { + _pinFocus.requestFocus(); + } + }, ).init(), ] .map((e) => Padding(