diff --git a/lib/app/views/navigation.dart b/lib/app/views/navigation.dart index 2315da16..c3fa0191 100644 --- a/lib/app/views/navigation.dart +++ b/lib/app/views/navigation.dart @@ -89,7 +89,7 @@ extension on Application { IconData get _icon => switch (this) { Application.oath => Icons.supervisor_account_outlined, Application.fido => Icons.security_outlined, - Application.otp => Icons.password_outlined, + Application.otp => Icons.touch_app_outlined, Application.piv => Icons.approval_outlined, Application.management => Icons.construction_outlined, Application.openpgp => Icons.key_outlined, @@ -99,7 +99,7 @@ extension on Application { IconData get _filledIcon => switch (this) { Application.oath => Icons.supervisor_account, Application.fido => Icons.security, - Application.otp => Icons.password, + Application.otp => Icons.touch_app, Application.piv => Icons.approval, Application.management => Icons.construction, Application.openpgp => Icons.key, diff --git a/lib/core/models.dart b/lib/core/models.dart index 4187431f..b24d5dde 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -155,3 +155,18 @@ class Version with _$Version implements Comparable { } final DateFormat dateFormatter = DateFormat('yyyy-MM-dd'); + +enum Format { + base32('a-z2-7'), + hex('abcdef0123456789'), + modhex('cbdefghijklnrtuv'); + + final String allowedCharacters; + + const Format(this.allowedCharacters); + + bool isValid(String input) { + return RegExp('^[$allowedCharacters]+\$', caseSensitive: false) + .hasMatch(input); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f818a870..786ae7cb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -61,9 +61,9 @@ "s_manage": "Manage", "s_setup": "Setup", "s_settings": "Settings", - "s_piv": "PIV", + "s_piv": "Certificates", "s_webauthn": "WebAuthn", - "s_otp": "OTP", + "s_otp": "Slots", "s_help_and_about": "Help and about", "s_help_and_feedback": "Help and feedback", "s_send_feedback": "Send us feedback", @@ -80,6 +80,13 @@ "s_private_key": "Private key", "s_invalid_length": "Invalid length", "s_invalid_format": "Invalid format", + "l_invalid_format_allowed_chars": "Invalid format, allowed characters: {characters}", + "@l_invalid_format_allowed_chars": { + "placeholders": { + "characters": {} + } + }, + "l_invalid_keyboard_character": "Invalid characters for selected keyboard", "s_require_touch": "Require touch", "q_have_account_info": "Have account info?", "s_run_diagnostics": "Run diagnostics", @@ -508,27 +515,20 @@ "@_otp_slots": {}, "s_otp_slots": "Slots", - "s_otp_slot_display_name": "{name} (Slot {id})", - "@s_otp_slot_display_name" : { - "placeholders": { - "name": {}, - "id": {} - } - }, "s_otp_slot_one": "Short touch", "s_otp_slot_two": "Long touch", - "l_otp_slot_not_programmed": "Slot is empty", - "l_otp_slot_programmed": "Slot is programmed", + "l_otp_slot_empty": "Slot is empty", + "l_otp_slot_configured": "Slot is configured", "@_otp_slot_configurations": {}, "s_yubiotp": "Yubico OTP", - "l_yubiotp_desc": "YubiCloud verified OTP's", + "l_yubiotp_desc": "Program a Yubico OTP credential", "s_challenge_response": "Challenge-response", - "l_challenge_response_desc": "Show me yours and I'll show you mine", + "l_challenge_response_desc": "Program a challenge-response credential", "s_static_password": "Static password", - "l_static_password_desc": "Use complex passwords with a touch", + "l_static_password_desc": "Configure a static password", "s_hotp": "OATH-HOTP", - "l_hotp_desc": "HMAC based OTP's", + "l_hotp_desc": "Program a HMAC-SHA1 based credential", "s_public_id": "Public ID", "s_private_id": "Private ID", "s_allow_any_character": "Allow any character", @@ -538,34 +538,34 @@ "s_generate_passowrd": "Generate password", "@_otp_slot_actions": {}, - "s_delete_slot": "Delete slot", - "l_delete_slot_desc": "Delete configuration in slot", - "p_warning_delete_slot_configuration": "Warning! This action will delete the configuration in OTP slot {slot}.", + "s_delete_slot": "Delete credential", + "l_delete_slot_desc": "Remove credential in slot", + "p_warning_delete_slot_configuration": "Warning! This action will permanently remove the credential from slot {slot_id}.", "@p_warning_delete_slot_configuration": { "placeholders": { - "slot": {} + "slot_id": {} } }, - "l_slot_deleted": "Slot configuration deleted", + "l_slot_deleted": "Credential deleted", "s_swap": "Swap", "s_swap_slots": "Swap slots", "l_swap_slots_desc": "Swap short/long touch", "p_swap_slots_desc": "This will swap the configuration of the two slots.", "l_slots_swapped": "Slot configurations swapped", - "l_slot_configuration_programmed": "Configured {type} credential", - "@l_slot_configuration_programmed": { + "l_slot_credential_configured": "Configured {type} credential", + "@l_slot_credential_configured": { "placeholders": { "type": {} } }, - "l_slot_configuration_programmed_and_exported": "Configured {type} credential and exported to {file}", - "@l_slot_configuration_programmed_and_exported": { + "l_slot_credential_configured_and_exported": "Configured {type} credential and exported to {file}", + "@l_slot_credential_configured_and_exported": { "placeholders": { "type": {}, "file": {} } }, - "s_append_enter": "Append enter", + "s_append_enter": "Append ⏎", "l_append_enter_desc": "Append an Enter keystroke after emitting the OTP", "@_otp_errors": {}, @@ -576,6 +576,7 @@ } }, + "@_permissions": {}, "s_enable_nfc": "Enable NFC", "s_permission_denied": "Permission denied", diff --git a/lib/otp/models.dart b/lib/otp/models.dart index 30d05899..526419f8 100644 --- a/lib/otp/models.dart +++ b/lib/otp/models.dart @@ -29,10 +29,9 @@ enum SlotId { const SlotId(this.id, this.numberId); String getDisplayName(AppLocalizations l10n) { - String nameFor(String name) => l10n.s_otp_slot_display_name(name, numberId); return switch (this) { - SlotId.one => nameFor(l10n.s_otp_slot_one), - SlotId.two => nameFor(l10n.s_otp_slot_two) + SlotId.one => l10n.s_otp_slot_one, + SlotId.two => l10n.s_otp_slot_two }; } diff --git a/lib/otp/views/configure_chalresp_dialog.dart b/lib/otp/views/configure_chalresp_dialog.dart index 239e1f4a..8cf38f21 100644 --- a/lib/otp/views/configure_chalresp_dialog.dart +++ b/lib/otp/views/configure_chalresp_dialog.dart @@ -17,10 +17,10 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yubico_authenticator/app/logging.dart'; +import 'package:yubico_authenticator/core/models.dart'; import 'package:yubico_authenticator/core/state.dart'; import 'package:logging/logging.dart'; @@ -49,6 +49,7 @@ class _ConfigureChalrespDialogState extends ConsumerState { final _secretController = TextEditingController(); bool _validateSecretLength = false; + bool _validateSecretFormat = false; bool _requireTouch = false; final int secretMaxLength = 40; @@ -62,9 +63,11 @@ class _ConfigureChalrespDialogState Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final secret = _secretController.text.replaceAll(' ', ''); - final secretLengthValid = - secret.isNotEmpty && secret.length <= secretMaxLength; + final secret = _secretController.text; + final secretLengthValid = secret.isNotEmpty && + secret.length % 2 == 0 && + secret.length <= secretMaxLength; + final secretFormatValid = Format.hex.isValid(secret); return ResponsiveDialog( title: Text(l10n.s_challenge_response), @@ -79,6 +82,12 @@ class _ConfigureChalrespDialogState }); return; } + if (!secretFormatValid) { + setState(() { + _validateSecretFormat = true; + }); + return; + } if (!await confirmOverwrite(context, widget.otpSlot)) { return; @@ -94,8 +103,10 @@ class _ConfigureChalrespDialogState requireTouch: _requireTouch))); await ref.read(withContextProvider)((context) async { Navigator.of(context).pop(); - showMessage(context, - l10n.l_slot_configuration_programmed(l10n.s_hotp)); + showMessage( + context, + l10n.l_slot_credential_configured( + l10n.s_challenge_response)); }); } catch (e) { _log.error('Failed to program credential', e); @@ -125,36 +136,48 @@ class _ConfigureChalrespDialogState autofillHints: isAndroid ? [] : const [AutofillHints.password], maxLength: secretMaxLength, decoration: InputDecoration( - suffixIcon: IconButton( - tooltip: l10n.s_generate_secret_key, - icon: const Icon(Icons.refresh), - onPressed: () { - final random = Random.secure(); - final key = List.generate( - 20, - (_) => random - .nextInt(256) - .toRadixString(16) - .padLeft(2, '0')).join(); - setState(() { - _secretController.text = key; - }); - }, + suffixIcon: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + setState(() { + final random = Random.secure(); + final key = List.generate( + 20, + (_) => random + .nextInt(256) + .toRadixString(16) + .padLeft(2, '0')).join(); + setState(() { + _secretController.text = key; + }); + }); + }, + ), + if (_validateSecretLength || _validateSecretFormat) ...[ + const Icon(Icons.error_outlined), + const SizedBox( + width: 8.0, + ) + ] + ], ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.key_outlined), labelText: l10n.s_secret_key, errorText: _validateSecretLength && !secretLengthValid ? l10n.s_invalid_length - : null), - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp('[a-f0-9]', caseSensitive: false)) - ], + : _validateSecretFormat && !secretFormatValid + ? l10n.l_invalid_format_allowed_chars( + Format.hex.allowedCharacters) + : null), textInputAction: TextInputAction.next, onChanged: (value) { setState(() { _validateSecretLength = false; + _validateSecretFormat = false; }); }, ), diff --git a/lib/otp/views/configure_hotp_dialog.dart b/lib/otp/views/configure_hotp_dialog.dart index a6dce0fb..ad856af2 100644 --- a/lib/otp/views/configure_hotp_dialog.dart +++ b/lib/otp/views/configure_hotp_dialog.dart @@ -15,10 +15,10 @@ */ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yubico_authenticator/app/logging.dart'; +import 'package:yubico_authenticator/core/models.dart'; import 'package:yubico_authenticator/core/state.dart'; import 'package:yubico_authenticator/oath/models.dart'; import 'package:logging/logging.dart'; @@ -48,6 +48,7 @@ class ConfigureHotpDialog extends ConsumerStatefulWidget { class _ConfigureHotpDialogState extends ConsumerState { final _secretController = TextEditingController(); bool _validateSecretLength = false; + bool _validateSecretFormat = false; int _digits = defaultDigits; final List _digitsValues = [6, 8]; bool _appendEnter = true; @@ -65,6 +66,7 @@ class _ConfigureHotpDialogState extends ConsumerState { final secret = _secretController.text.replaceAll(' ', ''); final secretLengthValid = secret.isNotEmpty && secret.length * 5 % 8 < 5; + final secretFormatValid = Format.base32.isValid(secret); return ResponsiveDialog( title: Text(l10n.s_hotp), @@ -79,6 +81,12 @@ class _ConfigureHotpDialogState extends ConsumerState { }); return; } + if (!secretFormatValid) { + setState(() { + _validateSecretFormat = true; + }); + return; + } if (!await confirmOverwrite(context, widget.otpSlot)) { return; @@ -96,7 +104,7 @@ class _ConfigureHotpDialogState extends ConsumerState { await ref.read(withContextProvider)((context) async { Navigator.of(context).pop(); showMessage(context, - l10n.l_slot_configuration_programmed(l10n.s_hotp)); + l10n.l_slot_credential_configured(l10n.s_hotp)); }); } catch (e) { _log.error('Failed to program credential', e); @@ -124,42 +132,66 @@ class _ConfigureHotpDialogState extends ConsumerState { controller: _secretController, obscureText: _isObscure, autofillHints: isAndroid ? [] : const [AutofillHints.password], - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp( - '[abcdefghijklmnopqrstuvwxyz234567 ]', - caseSensitive: false)) - ], decoration: InputDecoration( - suffixIcon: IconButton( - icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off, - color: IconTheme.of(context).color, - ), - onPressed: () { - setState(() { - _isObscure = !_isObscure; - }); - }, + suffixIcon: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + icon: Icon( + _isObscure + ? Icons.visibility + : Icons.visibility_off, + color: !(_validateSecretLength || + _validateSecretFormat) + ? IconTheme.of(context).color + : null), + onPressed: () { + setState(() { + _isObscure = !_isObscure; + }); + }, + ), + if (_validateSecretLength || _validateSecretFormat) ...[ + const Icon(Icons.error_outlined), + const SizedBox( + width: 8.0, + ) + ] + ], ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.key_outlined), labelText: l10n.s_secret_key, + helperText: '', // Prevents resizing when errorText shown errorText: _validateSecretLength && !secretLengthValid ? l10n.s_invalid_length - : null), + : _validateSecretFormat && !secretFormatValid + ? l10n.l_invalid_format_allowed_chars( + Format.base32.allowedCharacters) + : null), textInputAction: TextInputAction.next, onChanged: (value) { setState(() { _validateSecretLength = false; + _validateSecretFormat = false; }); }, ), - const SizedBox(height: 8), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, runSpacing: 8.0, children: [ + FilterChip( + label: Text(l10n.s_append_enter), + tooltip: l10n.l_append_enter_desc, + selected: _appendEnter, + onSelected: (value) { + setState(() { + _appendEnter = value; + }); + }, + ), ChoiceFilterChip( items: _digitsValues, value: _digits, @@ -170,16 +202,6 @@ class _ConfigureHotpDialogState extends ConsumerState { _digits = digits; }); }), - FilterChip( - label: Text(l10n.s_append_enter), - tooltip: l10n.l_append_enter_desc, - selected: _appendEnter, - onSelected: (value) { - setState(() { - _appendEnter = value; - }); - }, - ) ], ) ] diff --git a/lib/otp/views/configure_static_dialog.dart b/lib/otp/views/configure_static_dialog.dart index a6427077..f3831560 100644 --- a/lib/otp/views/configure_static_dialog.dart +++ b/lib/otp/views/configure_static_dialog.dart @@ -15,7 +15,6 @@ */ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yubico_authenticator/app/logging.dart'; @@ -69,13 +68,13 @@ class _ConfigureStaticDialogState extends ConsumerState { super.dispose(); } - String generateFormatterPattern(String layout) { + RegExp generateFormatterPattern(String layout) { final allowedCharacters = widget.keyboardLayouts[layout] ?? []; final pattern = allowedCharacters.map((char) => RegExp.escape(char)).join(''); - return '[$pattern]'; + return RegExp('^[$pattern]+\$', caseSensitive: false); } @override @@ -85,10 +84,8 @@ class _ConfigureStaticDialogState extends ConsumerState { final password = _passwordController.text.replaceAll(' ', ''); final passwordLengthValid = password.isNotEmpty && password.length <= passwordMaxLength; - - final layoutPattern = generateFormatterPattern(_keyboardLayout); - final regex = RegExp('^$layoutPattern', caseSensitive: false); - final passwordFormatValid = regex.hasMatch(password); + final passwordFormatValid = + generateFormatterPattern(_keyboardLayout).hasMatch(password); return ResponsiveDialog( title: Text(l10n.s_static_password), @@ -121,7 +118,7 @@ class _ConfigureStaticDialogState extends ConsumerState { Navigator.of(context).pop(); showMessage( context, - l10n.l_slot_configuration_programmed( + l10n.l_slot_credential_configured( l10n.s_static_password)); }); } catch (e) { @@ -152,18 +149,31 @@ class _ConfigureStaticDialogState extends ConsumerState { autofillHints: isAndroid ? [] : const [AutofillHints.password], maxLength: passwordMaxLength, decoration: InputDecoration( - suffixIcon: IconButton( - tooltip: l10n.s_generate_passowrd, - icon: const Icon(Icons.refresh), - onPressed: () async { - final password = await ref - .read(otpStateProvider(widget.devicePath).notifier) - .generateStaticPassword( - passwordMaxLength, _keyboardLayout); - setState(() { - _passwordController.text = password; - }); - }, + suffixIcon: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + tooltip: l10n.s_generate_passowrd, + icon: const Icon(Icons.refresh), + onPressed: () async { + final password = await ref + .read( + otpStateProvider(widget.devicePath).notifier) + .generateStaticPassword( + passwordMaxLength, _keyboardLayout); + setState(() { + _validatePassword = false; + _passwordController.text = password; + }); + }, + ), + if (_validatePassword) ...[ + const Icon(Icons.error_outlined), + const SizedBox( + width: 8.0, + ) + ] + ], ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.key_outlined), @@ -175,13 +185,8 @@ class _ConfigureStaticDialogState extends ConsumerState { : _validatePassword && passwordLengthValid && !passwordFormatValid - ? l10n.s_invalid_format + ? l10n.l_invalid_keyboard_character : null), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp( - generateFormatterPattern(_keyboardLayout), - caseSensitive: false)) - ], textInputAction: TextInputAction.next, onChanged: (value) { setState(() { @@ -194,17 +199,6 @@ class _ConfigureStaticDialogState extends ConsumerState { spacing: 4.0, runSpacing: 8.0, children: [ - ChoiceFilterChip( - items: widget.keyboardLayouts.keys.toList(), - value: _keyboardLayout, - selected: _keyboardLayout != _defaultKeyboardLayout, - itemBuilder: (value) => Text(value), - onChanged: (layout) { - setState(() { - _keyboardLayout = layout; - _validatePassword = false; - }); - }), FilterChip( label: Text(l10n.s_append_enter), tooltip: l10n.l_append_enter_desc, @@ -214,7 +208,19 @@ class _ConfigureStaticDialogState extends ConsumerState { _appendEnter = value; }); }, - ) + ), + ChoiceFilterChip( + items: widget.keyboardLayouts.keys.toList(), + value: _keyboardLayout, + selected: _keyboardLayout != _defaultKeyboardLayout, + labelBuilder: (value) => Text('Keyboard $value'), + itemBuilder: (value) => Text(value), + onChanged: (layout) { + setState(() { + _keyboardLayout = layout; + _validatePassword = false; + }); + }), ], ) ] diff --git a/lib/otp/views/configure_yubiotp_dialog.dart b/lib/otp/views/configure_yubiotp_dialog.dart index 8a6235f2..ec3b5681 100644 --- a/lib/otp/views/configure_yubiotp_dialog.dart +++ b/lib/otp/views/configure_yubiotp_dialog.dart @@ -19,10 +19,10 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yubico_authenticator/app/logging.dart'; +import 'package:yubico_authenticator/core/models.dart'; import 'package:yubico_authenticator/core/state.dart'; import 'package:logging/logging.dart'; import 'package:yubico_authenticator/widgets/choice_filter_chip.dart'; @@ -38,8 +38,6 @@ import 'overwrite_confirm_dialog.dart'; final _log = Logger('otp.view.configure_yubiotp_dialog'); -final _modhexPattern = RegExp('[cbdefghijklnrtuv]', caseSensitive: false); - enum OutputActions { selectFile, noOutput; @@ -48,7 +46,7 @@ enum OutputActions { String getDisplayName(AppLocalizations l10n) => switch (this) { OutputActions.selectFile => 'Select file', - OutputActions.noOutput => 'No output' + OutputActions.noOutput => 'No export file' }; } @@ -67,11 +65,14 @@ class _ConfigureYubiOtpDialogState final _secretController = TextEditingController(); final _publicIdController = TextEditingController(); final _privateIdController = TextEditingController(); + OutputActions _action = OutputActions.noOutput; + bool _appendEnter = true; + bool _validateSecretFormat = false; + bool _validatePublicIdFormat = false; + bool _validatePrivateIdFormat = false; final secretLength = 32; final publicIdLength = 12; final privateIdLength = 12; - OutputActions _action = OutputActions.selectFile; - bool _appendEnter = true; @override void dispose() { @@ -87,16 +88,19 @@ class _ConfigureYubiOtpDialogState final info = ref.watch(currentDeviceDataProvider).valueOrNull?.info; - final secret = _secretController.text.replaceAll(' ', ''); + final secret = _secretController.text; final secretLengthValid = secret.length == secretLength; + final secretFormatValid = Format.hex.isValid(secret); final privateId = _privateIdController.text; final privateIdLengthValid = privateId.length == privateIdLength; + final privatedIdFormatValid = Format.hex.isValid(privateId); final publicId = _publicIdController.text; final publicIdLengthValid = publicId.length == publicIdLength; + final publicIdFormatValid = Format.modhex.isValid(publicId); - final isValid = + final lengthsValid = secretLengthValid && privateIdLengthValid && publicIdLengthValid; final outputFile = ref.read(yubiOtpOutputProvider); @@ -121,8 +125,19 @@ class _ConfigureYubiOtpDialogState actions: [ TextButton( key: keys.saveButton, - onPressed: isValid + onPressed: lengthsValid ? () async { + if (!secretFormatValid || + !publicIdFormatValid || + !privatedIdFormatValid) { + setState(() { + _validateSecretFormat = !secretFormatValid; + _validatePublicIdFormat = !publicIdFormatValid; + _validatePrivateIdFormat = !privatedIdFormatValid; + }); + return; + } + if (!await confirmOverwrite(context, widget.otpSlot)) { return; } @@ -150,11 +165,10 @@ class _ConfigureYubiOtpDialogState showMessage( context, outputFile != null - ? l10n - .l_slot_configuration_programmed_and_exported( - l10n.s_yubiotp, - outputFile.uri.pathSegments.last) - : l10n.l_slot_configuration_programmed( + ? l10n.l_slot_credential_configured_and_exported( + l10n.s_yubiotp, + outputFile.uri.pathSegments.last) + : l10n.l_slot_credential_configured( l10n.s_yubiotp)); }); } catch (e) { @@ -191,31 +205,43 @@ class _ConfigureYubiOtpDialogState autofillHints: isAndroid ? [] : const [AutofillHints.password], maxLength: publicIdLength, decoration: InputDecoration( - suffixIcon: IconButton( - tooltip: l10n.s_use_serial, - icon: const Icon(Icons.auto_awesome_outlined), - onPressed: (info?.serial != null) - ? () async { - final publicId = await ref - .read(otpStateProvider(widget.devicePath) - .notifier) - .modhexEncodeSerial(info!.serial!); - setState(() { - _publicIdController.text = publicId; - }); - } - : null, + suffixIcon: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + tooltip: l10n.s_use_serial, + icon: const Icon(Icons.auto_awesome_outlined), + onPressed: (info?.serial != null) + ? () async { + final publicId = await ref + .read(otpStateProvider(widget.devicePath) + .notifier) + .modhexEncodeSerial(info!.serial!); + setState(() { + _publicIdController.text = publicId; + }); + } + : null, + ), + if (_validatePublicIdFormat) ...[ + const Icon(Icons.error_outlined), + const SizedBox( + width: 8.0, + ) + ] + ], ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.public_outlined), + errorText: _validatePublicIdFormat && !publicIdFormatValid + ? l10n.l_invalid_format_allowed_chars( + Format.modhex.allowedCharacters) + : null, labelText: l10n.s_public_id), - inputFormatters: [ - FilteringTextInputFormatter.allow(_modhexPattern) - ], textInputAction: TextInputAction.next, onChanged: (value) { setState(() { - // Update lengths + _validatePublicIdFormat = false; }); }, ), @@ -225,33 +251,44 @@ class _ConfigureYubiOtpDialogState autofillHints: isAndroid ? [] : const [AutofillHints.password], maxLength: privateIdLength, decoration: InputDecoration( - suffixIcon: IconButton( - tooltip: l10n.s_generate_private_id, - icon: const Icon(Icons.refresh), - onPressed: () { - final random = Random.secure(); - final key = List.generate( - 6, - (_) => random - .nextInt(256) - .toRadixString(16) - .padLeft(2, '0')).join(); - setState(() { - _privateIdController.text = key; - }); - }, + suffixIcon: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + tooltip: l10n.s_generate_private_id, + icon: const Icon(Icons.refresh), + onPressed: () { + final random = Random.secure(); + final key = List.generate( + 6, + (_) => random + .nextInt(256) + .toRadixString(16) + .padLeft(2, '0')).join(); + setState(() { + _privateIdController.text = key; + }); + }, + ), + if (_validatePrivateIdFormat) ...[ + const Icon(Icons.error_outlined), + const SizedBox( + width: 8.0, + ) + ] + ], ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.key_outlined), + errorText: _validatePrivateIdFormat && !privatedIdFormatValid + ? l10n.l_invalid_format_allowed_chars( + Format.hex.allowedCharacters) + : null, labelText: l10n.s_private_id), - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp('[a-f0-9]', caseSensitive: false)) - ], textInputAction: TextInputAction.next, onChanged: (value) { setState(() { - // Update lengths + _validatePrivateIdFormat = false; }); }, ), @@ -261,33 +298,44 @@ class _ConfigureYubiOtpDialogState autofillHints: isAndroid ? [] : const [AutofillHints.password], maxLength: secretLength, decoration: InputDecoration( - suffixIcon: IconButton( - tooltip: l10n.s_generate_secret_key, - icon: const Icon(Icons.refresh), - onPressed: () { - final random = Random.secure(); - final key = List.generate( - 16, - (_) => random - .nextInt(256) - .toRadixString(16) - .padLeft(2, '0')).join(); - setState(() { - _secretController.text = key; - }); - }, + suffixIcon: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + tooltip: l10n.s_generate_secret_key, + icon: const Icon(Icons.refresh), + onPressed: () { + final random = Random.secure(); + final key = List.generate( + 16, + (_) => random + .nextInt(256) + .toRadixString(16) + .padLeft(2, '0')).join(); + setState(() { + _secretController.text = key; + }); + }, + ), + if (_validateSecretFormat) ...[ + const Icon(Icons.error_outlined), + const SizedBox( + width: 8.0, + ) + ] + ], ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.key_outlined), + errorText: _validateSecretFormat && !secretFormatValid + ? l10n.l_invalid_format_allowed_chars( + Format.hex.allowedCharacters) + : null, labelText: l10n.s_secret_key), - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp('[a-f0-9]', caseSensitive: false)) - ], textInputAction: TextInputAction.next, onChanged: (value) { setState(() { - // Update lengths + _validateSecretFormat = false; }); }, ), @@ -321,7 +369,9 @@ class _ConfigureYubiOtpDialogState return Container( constraints: const BoxConstraints(maxWidth: 140), child: Text( - 'Output ${fileName ?? 'No output'}', + fileName != null + ? 'Export $fileName' + : _action.getDisplayName(l10n), overflow: TextOverflow.ellipsis, ), ); diff --git a/lib/otp/views/delete_slot_dialog.dart b/lib/otp/views/delete_slot_dialog.dart index 73ee0653..1a88e1b2 100644 --- a/lib/otp/views/delete_slot_dialog.dart +++ b/lib/otp/views/delete_slot_dialog.dart @@ -35,7 +35,7 @@ class DeleteSlotDialog extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; return ResponsiveDialog( - title: Text(l10n.l_delete_certificate), + title: Text(l10n.s_delete_slot), actions: [ TextButton( key: keys.deleteButton, @@ -68,8 +68,8 @@ class DeleteSlotDialog extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.p_warning_delete_slot_configuration( - otpSlot.slot.getDisplayName(l10n))), + Text(l10n + .p_warning_delete_slot_configuration(otpSlot.slot.numberId)), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/otp/views/otp_screen.dart b/lib/otp/views/otp_screen.dart index 0193b5a0..0c145022 100644 --- a/lib/otp/views/otp_screen.dart +++ b/lib/otp/views/otp_screen.dart @@ -97,11 +97,10 @@ class _SlotListItem extends ConsumerWidget { leading: CircleAvatar( foregroundColor: colorScheme.onSecondary, backgroundColor: colorScheme.secondary, - child: const Icon(Icons.password_outlined)), + child: Text(slot.numberId.toString())), title: slot.getDisplayName(l10n), - subtitle: isConfigured - ? l10n.l_otp_slot_programmed - : l10n.l_otp_slot_not_programmed, + subtitle: + isConfigured ? l10n.l_otp_slot_configured : l10n.l_otp_slot_empty, trailing: OutlinedButton( onPressed: Actions.handler(context, const OpenIntent()), child: const Icon(Icons.more_horiz), diff --git a/lib/otp/views/slot_dialog.dart b/lib/otp/views/slot_dialog.dart index e716f9f2..73cee688 100644 --- a/lib/otp/views/slot_dialog.dart +++ b/lib/otp/views/slot_dialog.dart @@ -72,13 +72,17 @@ class SlotDialog extends ConsumerWidget { const Icon( Icons.touch_app, size: 100.0, - ) + ), + const SizedBox(height: 8), + Text(otpSlot.isConfigured + ? l10n.l_otp_slot_configured + : l10n.l_otp_slot_empty) ], ), ), ActionListSection.fromMenuActions( context, - l10n.s_actions, + l10n.s_setup, actions: buildSlotActions(otpSlot.isConfigured, l10n), ) ],