Enhance error handling in OTP.

This commit is contained in:
Elias Bonnici 2023-11-23 16:25:11 +01:00
parent 23ebcb6955
commit 63bb18b2be
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
11 changed files with 324 additions and 205 deletions

View File

@ -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,

View File

@ -155,3 +155,18 @@ class Version with _$Version implements Comparable<Version> {
}
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);
}
}

View File

@ -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",

View File

@ -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
};
}

View File

@ -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<ConfigureChalrespDialog> {
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: <TextInputFormatter>[
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;
});
},
),

View File

@ -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<ConfigureHotpDialog> {
final _secretController = TextEditingController();
bool _validateSecretLength = false;
bool _validateSecretFormat = false;
int _digits = defaultDigits;
final List<int> _digitsValues = [6, 8];
bool _appendEnter = true;
@ -65,6 +66,7 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
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<ConfigureHotpDialog> {
});
return;
}
if (!secretFormatValid) {
setState(() {
_validateSecretFormat = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
@ -96,7 +104,7 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
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<ConfigureHotpDialog> {
controller: _secretController,
obscureText: _isObscure,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
inputFormatters: <TextInputFormatter>[
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<int>(
items: _digitsValues,
value: _digits,
@ -170,16 +202,6 @@ class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
_digits = digits;
});
}),
FilterChip(
label: Text(l10n.s_append_enter),
tooltip: l10n.l_append_enter_desc,
selected: _appendEnter,
onSelected: (value) {
setState(() {
_appendEnter = value;
});
},
)
],
)
]

View File

@ -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<ConfigureStaticDialog> {
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<ConfigureStaticDialog> {
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<ConfigureStaticDialog> {
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<ConfigureStaticDialog> {
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<ConfigureStaticDialog> {
: _validatePassword &&
passwordLengthValid &&
!passwordFormatValid
? l10n.s_invalid_format
? l10n.l_invalid_keyboard_character
: null),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(
generateFormatterPattern(_keyboardLayout),
caseSensitive: false))
],
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
@ -194,17 +199,6 @@ class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
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<ConfigureStaticDialog> {
_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;
});
}),
],
)
]

View File

@ -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: <TextInputFormatter>[
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: <TextInputFormatter>[
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: <TextInputFormatter>[
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,
),
);

View File

@ -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),

View File

@ -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),

View File

@ -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),
)
],