This commit is contained in:
Elias Bonnici 2023-12-15 14:40:10 +01:00
commit 624f4aae4f
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
53 changed files with 4759 additions and 262 deletions

View File

@ -413,7 +413,9 @@ class ConnectionNode(RpcNode):
or ( # SmartCardConnection can be used over NFC, or on 5.3 and later. or ( # SmartCardConnection can be used over NFC, or on 5.3 and later.
isinstance(self._connection, SmartCardConnection) isinstance(self._connection, SmartCardConnection)
and ( and (
self._transport == TRANSPORT.NFC or self._info.version >= (5, 3, 0) self._transport == TRANSPORT.NFC
or self._info.version >= (5, 3, 0)
or self._info.version[0] == 3
) )
) )
) )

View File

@ -14,7 +14,8 @@
from .base import RpcNode, action, child from .base import RpcNode, action, child
from yubikit.core import NotSupportedError from yubikit.core import NotSupportedError, CommandError
from yubikit.core.otp import modhex_encode, modhex_decode
from yubikit.yubiotp import ( from yubikit.yubiotp import (
YubiOtpSession, YubiOtpSession,
SLOT, SLOT,
@ -25,7 +26,17 @@ from yubikit.yubiotp import (
YubiOtpSlotConfiguration, YubiOtpSlotConfiguration,
StaticTicketSlotConfiguration, StaticTicketSlotConfiguration,
) )
from ykman.otp import generate_static_pw, format_csv
from yubikit.oath import parse_b32_key
from ykman.scancodes import KEYBOARD_LAYOUT, encode
from typing import Dict from typing import Dict
import struct
_FAIL_MSG = (
"Failed to write to the YubiKey. Make sure the device does not "
"have restricted access"
)
class YubiOtpNode(RpcNode): class YubiOtpNode(RpcNode):
@ -65,6 +76,29 @@ class YubiOtpNode(RpcNode):
def two(self): def two(self):
return SlotNode(self.session, SLOT.TWO) return SlotNode(self.session, SLOT.TWO)
@action(closes_child=False)
def serial_modhex(self, params, event, signal):
serial = params["serial"]
return dict(encoded=modhex_encode(b"\xff\x00" + struct.pack(b">I", serial)))
@action(closes_child=False)
def generate_static(self, params, event, signal):
layout, length = params["layout"], int(params["length"])
return dict(password=generate_static_pw(length, KEYBOARD_LAYOUT[layout]))
@action(closes_child=False)
def keyboard_layouts(self, params, event, signal):
return {layout.name: [sc for sc in layout.value] for layout in KEYBOARD_LAYOUT}
@action(closes_child=False)
def format_yubiotp_csv(self, params, even, signal):
serial = params["serial"]
public_id = modhex_decode(params["public_id"])
private_id = bytes.fromhex(params["private_id"])
key = bytes.fromhex(params["key"])
return dict(csv=format_csv(serial, public_id, private_id, key))
_CONFIG_TYPES = dict( _CONFIG_TYPES = dict(
hmac_sha1=HmacSha1SlotConfiguration, hmac_sha1=HmacSha1SlotConfiguration,
@ -113,7 +147,10 @@ class SlotNode(RpcNode):
@action(condition=lambda self: self._maybe_configured(self.slot)) @action(condition=lambda self: self._maybe_configured(self.slot))
def delete(self, params, event, signal): def delete(self, params, event, signal):
self.session.delete_slot(self.slot, params.pop("cur_acc_code", None)) try:
self.session.delete_slot(self.slot, params.pop("cur_acc_code", None))
except CommandError:
raise ValueError(_FAIL_MSG)
@action(condition=lambda self: self._can_calculate(self.slot)) @action(condition=lambda self: self._can_calculate(self.slot))
def calculate(self, params, event, signal): def calculate(self, params, event, signal):
@ -121,7 +158,7 @@ class SlotNode(RpcNode):
response = self.session.calculate_hmac_sha1(self.slot, challenge, event) response = self.session.calculate_hmac_sha1(self.slot, challenge, event)
return dict(response=response) return dict(response=response)
def _apply_config(self, config, params): def _apply_options(self, config, options):
for option in ( for option in (
"serial_api_visible", "serial_api_visible",
"serial_usb_visible", "serial_usb_visible",
@ -140,39 +177,61 @@ class SlotNode(RpcNode):
"short_ticket", "short_ticket",
"manual_update", "manual_update",
): ):
if option in params: if option in options:
getattr(config, option)(params.pop(option)) getattr(config, option)(options.pop(option))
for option in ("tabs", "delay", "pacing", "strong_password"): for option in ("tabs", "delay", "pacing", "strong_password"):
if option in params: if option in options:
getattr(config, option)(*params.pop(option)) getattr(config, option)(*options.pop(option))
if "token_id" in params: if "token_id" in options:
token_id, *args = params.pop("token_id") token_id, *args = options.pop("token_id")
config.token_id(bytes.fromhex(token_id), *args) config.token_id(bytes.fromhex(token_id), *args)
return config return config
def _get_config(self, type, **kwargs):
config = None
if type in _CONFIG_TYPES:
if type == "hmac_sha1":
config = _CONFIG_TYPES[type](bytes.fromhex(kwargs["key"]))
elif type == "hotp":
config = _CONFIG_TYPES[type](parse_b32_key(kwargs["key"]))
elif type == "static_password":
config = _CONFIG_TYPES[type](
encode(
kwargs["password"], KEYBOARD_LAYOUT[kwargs["keyboard_layout"]]
)
)
elif type == "yubiotp":
config = _CONFIG_TYPES[type](
fixed=modhex_decode(kwargs["public_id"]),
uid=bytes.fromhex(kwargs["private_id"]),
key=bytes.fromhex(kwargs["key"]),
)
else:
raise ValueError("No supported configuration type provided.")
return config
@action @action
def put(self, params, event, signal): def put(self, params, event, signal):
config = None type = params.pop("type")
for key in _CONFIG_TYPES: options = params.pop("options", {})
if key in params: args = params
if config is not None:
raise ValueError("Only one configuration type can be provided.") config = self._get_config(type, **args)
config = _CONFIG_TYPES[key]( self._apply_options(config, options)
*(bytes.fromhex(arg) for arg in params.pop(key)) try:
) self.session.put_configuration(
if config is None: self.slot,
raise ValueError("No supported configuration type provided.") config,
self._apply_config(config, params) params.pop("acc_code", None),
self.session.put_configuration( params.pop("cur_acc_code", None),
self.slot, )
config, return dict()
params.pop("acc_code", None), except CommandError:
params.pop("cur_acc_code", None), raise ValueError(_FAIL_MSG)
)
return dict()
@action( @action(
condition=lambda self: self._state.version >= (2, 2, 0) condition=lambda self: self._state.version >= (2, 2, 0)
@ -180,7 +239,7 @@ class SlotNode(RpcNode):
) )
def update(self, params, event, signal): def update(self, params, event, signal):
config = UpdateConfiguration() config = UpdateConfiguration()
self._apply_config(config, params) self._apply_options(config, params)
self.session.update_configuration( self.session.update_configuration(
self.slot, self.slot,
config, config,

View File

@ -0,0 +1,34 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@Tags(['android', 'desktop', 'oath'])
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:yubico_authenticator/app/views/keys.dart';
import 'utils/test_util.dart';
void main() {
var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
group('OTP UI tests', () {
appTest('OTP menu items exist', (WidgetTester tester) async {
await tester.tap(find.byKey(otpAppDrawer));
await tester.shortWait();
});
});
}

View File

@ -158,7 +158,7 @@ void main() {
const shortmanagementkey = const shortmanagementkey =
'aaaabbbbccccaaaabbbbccccaaaabbbbccccaaaabbbbccc'; 'aaaabbbbccccaaaabbbbccccaaaabbbbccccaaaabbbbccc';
appTest('Bad managementkey key', (WidgetTester tester) async { appTest('Out of bounds managementkey key', (WidgetTester tester) async {
await tester.configurePiv(); await tester.configurePiv();
await tester.shortWait(); await tester.shortWait();
await tester.tap(find.byKey(manageManagementKeyAction).hitTestable()); await tester.tap(find.byKey(manageManagementKeyAction).hitTestable());
@ -169,12 +169,22 @@ void main() {
await tester.longWait(); await tester.longWait();
await tester.tap(find.byKey(saveButton).hitTestable()); await tester.tap(find.byKey(saveButton).hitTestable());
await tester.longWait(); await tester.longWait();
expect(tester.isTextButtonEnabled(saveButton), true);
// TODO assert that errorText and errorIcon are shown
});
appTest('Short managementkey key', (WidgetTester tester) async {
await tester.configurePiv();
await tester.shortWait();
await tester.tap(find.byKey(manageManagementKeyAction).hitTestable());
await tester.longWait();
// testing too short management key does not work // testing too short management key does not work
await tester.enterText( await tester.enterText(
find.byKey(newPinPukField).hitTestable(), shortmanagementkey); find.byKey(newPinPukField).hitTestable(), shortmanagementkey);
await tester.longWait(); await tester.longWait();
expect(tester.isTextButtonEnabled(saveButton), false); expect(tester.isTextButtonEnabled(saveButton), false);
}); });
appTest('Change managementkey key', (WidgetTester tester) async { appTest('Change managementkey key', (WidgetTester tester) async {
await tester.configurePiv(); await tester.configurePiv();
await tester.shortWait(); await tester.shortWait();

View File

@ -54,7 +54,8 @@ enum Application {
String getDisplayName(AppLocalizations l10n) => switch (this) { String getDisplayName(AppLocalizations l10n) => switch (this) {
Application.oath => l10n.s_authenticator, Application.oath => l10n.s_authenticator,
Application.fido => l10n.s_webauthn, Application.fido => l10n.s_webauthn,
Application.piv => l10n.s_piv, Application.piv => l10n.s_certificates,
Application.otp => l10n.s_slots,
_ => name.substring(0, 1).toUpperCase() + name.substring(1), _ => name.substring(0, 1).toUpperCase() + name.substring(1),
}; };

View File

@ -25,6 +25,7 @@ import '../../core/state.dart';
import '../../exception/cancellation_exception.dart'; import '../../exception/cancellation_exception.dart';
import '../../fido/views/fido_screen.dart'; import '../../fido/views/fido_screen.dart';
import '../../oath/views/oath_screen.dart'; import '../../oath/views/oath_screen.dart';
import '../../otp/views/otp_screen.dart';
import '../../piv/views/piv_screen.dart'; import '../../piv/views/piv_screen.dart';
import '../../widgets/custom_icons.dart'; import '../../widgets/custom_icons.dart';
import '../models.dart'; import '../models.dart';
@ -150,6 +151,7 @@ class MainPage extends ConsumerWidget {
Application.oath => OathScreen(data.node.path), Application.oath => OathScreen(data.node.path),
Application.fido => FidoScreen(data), Application.fido => FidoScreen(data),
Application.piv => PivScreen(data.node.path), Application.piv => PivScreen(data.node.path),
Application.otp => OtpScreen(data.node.path),
_ => MessagePage( _ => MessagePage(
header: l10n.s_app_not_supported, header: l10n.s_app_not_supported,
message: l10n.l_app_not_supported_desc, message: l10n.l_app_not_supported_desc,

View File

@ -89,7 +89,7 @@ extension on Application {
IconData get _icon => switch (this) { IconData get _icon => switch (this) {
Application.oath => Icons.supervisor_account_outlined, Application.oath => Icons.supervisor_account_outlined,
Application.fido => Icons.security_outlined, Application.fido => Icons.security_outlined,
Application.otp => Icons.password_outlined, Application.otp => Icons.touch_app_outlined,
Application.piv => Icons.approval_outlined, Application.piv => Icons.approval_outlined,
Application.management => Icons.construction_outlined, Application.management => Icons.construction_outlined,
Application.openpgp => Icons.key_outlined, Application.openpgp => Icons.key_outlined,
@ -99,7 +99,7 @@ extension on Application {
IconData get _filledIcon => switch (this) { IconData get _filledIcon => switch (this) {
Application.oath => Icons.supervisor_account, Application.oath => Icons.supervisor_account,
Application.fido => Icons.security, Application.fido => Icons.security,
Application.otp => Icons.password, Application.otp => Icons.touch_app,
Application.piv => Icons.approval, Application.piv => Icons.approval,
Application.management => Icons.construction, Application.management => Icons.construction,
Application.openpgp => Icons.key, Application.openpgp => Icons.key,

View File

@ -155,3 +155,18 @@ class Version with _$Version implements Comparable<Version> {
} }
final DateFormat dateFormatter = DateFormat('yyyy-MM-dd'); 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

@ -42,12 +42,14 @@ import '../core/state.dart';
import '../fido/state.dart'; import '../fido/state.dart';
import '../management/state.dart'; import '../management/state.dart';
import '../oath/state.dart'; import '../oath/state.dart';
import '../otp/state.dart';
import '../piv/state.dart'; import '../piv/state.dart';
import '../version.dart'; import '../version.dart';
import 'devices.dart'; import 'devices.dart';
import 'fido/state.dart'; import 'fido/state.dart';
import 'management/state.dart'; import 'management/state.dart';
import 'oath/state.dart'; import 'oath/state.dart';
import 'otp/state.dart';
import 'piv/state.dart'; import 'piv/state.dart';
import 'qr_scanner.dart'; import 'qr_scanner.dart';
import 'rpc.dart'; import 'rpc.dart';
@ -189,6 +191,7 @@ Future<Widget> initialize(List<String> argv) async {
Application.fido, Application.fido,
Application.piv, Application.piv,
Application.management, Application.management,
Application.otp
])), ])),
prefProvider.overrideWithValue(prefs), prefProvider.overrideWithValue(prefs),
rpcProvider.overrideWith((_) => rpcFuture), rpcProvider.overrideWith((_) => rpcFuture),
@ -226,6 +229,8 @@ Future<Widget> initialize(List<String> argv) async {
// PIV // PIV
pivStateProvider.overrideWithProvider(desktopPivState.call), pivStateProvider.overrideWithProvider(desktopPivState.call),
pivSlotsProvider.overrideWithProvider(desktopPivSlots.call), pivSlotsProvider.overrideWithProvider(desktopPivSlots.call),
// OTP
otpStateProvider.overrideWithProvider(desktopOtpState.call)
], ],
child: YubicoAuthenticatorApp( child: YubicoAuthenticatorApp(
page: Consumer( page: Consumer(

132
lib/desktop/otp/state.dart Normal file
View File

@ -0,0 +1,132 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/models.dart';
import '../../core/models.dart';
import '../../otp/models.dart';
import '../../otp/state.dart';
import '../rpc.dart';
import '../state.dart';
final _log = Logger('desktop.otp.state');
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
(ref, devicePath) =>
RpcNodeSession(ref.watch(rpcProvider).requireValue, devicePath, []),
);
final desktopOtpState = AsyncNotifierProvider.autoDispose
.family<OtpStateNotifier, OtpState, DevicePath>(
_DesktopOtpStateNotifier.new);
class _DesktopOtpStateNotifier extends OtpStateNotifier {
late RpcNodeSession _session;
List<String> _subpath = [];
@override
FutureOr<OtpState> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
_session.setErrorHandler('state-reset', (_) async {
ref.invalidate(_sessionProvider(devicePath));
});
ref.onDispose(() {
_session.unsetErrorHandler('state-reset');
});
final result = await _session.command('get');
final interfaces = (result['children'] as Map).keys.toSet();
// Will try to connect over ccid first
for (final iface in [UsbInterface.otp, UsbInterface.ccid]) {
if (interfaces.contains(iface.name)) {
final path = [iface.name, 'yubiotp'];
try {
final otpStateResult = await _session.command('get', target: path);
_subpath = path;
_log.debug('Using transport $iface for yubiotp');
_log.debug('application status', jsonEncode(result));
return OtpState.fromJson(otpStateResult['data']);
} catch (e) {
_log.warning('Failed connecting to yubiotp via $iface');
}
}
}
throw 'Failed connecting over ${UsbInterface.ccid.name} and ${UsbInterface.otp.name}';
}
@override
Future<void> swapSlots() async {
await _session.command('swap', target: _subpath);
ref.invalidate(_sessionProvider(_session.devicePath));
}
@override
Future<String> generateStaticPassword(int length, String layout) async {
final result = await _session.command('generate_static',
target: _subpath, params: {'length': length, 'layout': layout});
return result['password'];
}
@override
Future<String> modhexEncodeSerial(int serial) async {
final result = await _session
.command('serial_modhex', target: _subpath, params: {'serial': serial});
return result['encoded'];
}
@override
Future<Map<String, List<String>>> getKeyboardLayouts() async {
final result = await _session.command('keyboard_layouts', target: _subpath);
return Map<String, List<String>>.from(result.map((key, value) =>
MapEntry(key, (value as List<dynamic>).cast<String>().toList())));
}
@override
Future<String> formatYubiOtpCsv(
int serial, String publicId, String privateId, String key) async {
final result = await _session.command('format_yubiotp_csv',
target: _subpath,
params: {
'serial': serial,
'public_id': publicId,
'private_id': privateId,
'key': key
});
return result['csv'];
}
@override
Future<void> deleteSlot(SlotId slot) async {
await _session.command('delete', target: [..._subpath, slot.id]);
ref.invalidateSelf();
}
@override
Future<void> configureSlot(SlotId slot,
{required SlotConfiguration configuration}) async {
await _session.command('put',
target: [..._subpath, slot.id], params: configuration.toJson());
ref.invalidateSelf();
}
}

View File

@ -28,6 +28,7 @@ import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../fido/models.dart'; import '../../fido/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart'; import '../../widgets/app_text_form_field.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart'; import '../../widgets/utf8_utils.dart';
@ -206,7 +207,7 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
inputFormatters: [limitBytesLength(15)], inputFormatters: [limitBytesLength(15)],
buildCounter: buildByteCounterFor(_label), buildCounter: buildByteCounterFor(_label),
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: AppInputDecoration(
enabled: _fingerprint != null, enabled: _fingerprint != null,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_name, labelText: l10n.s_name,

View File

@ -23,6 +23,7 @@ import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart'; import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../features.dart' as features; import '../features.dart' as features;
import '../models.dart'; import '../models.dart';
@ -166,7 +167,7 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
obscureText: _isObscure, obscureText: _isObscure,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
controller: _pinController, controller: _pinController,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_pin, labelText: l10n.s_pin,
helperText: '', // Prevents dialog resizing helperText: '', // Prevents dialog resizing
@ -175,9 +176,8 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
prefixIcon: const Icon(Icons.pin_outlined), prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off, _isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color, color: !_pinIsWrong ? IconTheme.of(context).color : null),
),
onPressed: () { onPressed: () {
setState(() { setState(() {
_isObscure = !_isObscure; _isObscure = !_isObscure;

View File

@ -24,6 +24,7 @@ import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart'; import '../../widgets/app_text_form_field.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../models.dart'; import '../models.dart';
@ -48,6 +49,9 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
String? _newPinError; String? _newPinError;
bool _currentIsWrong = false; bool _currentIsWrong = false;
bool _newIsWrong = false; bool _newIsWrong = false;
bool _isObscureCurrent = true;
bool _isObscureNew = true;
bool _isObscureConfirm = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -76,14 +80,26 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
AppTextFormField( AppTextFormField(
initialValue: _currentPin, initialValue: _currentPin,
autofocus: true, autofocus: true,
obscureText: true, obscureText: _isObscureCurrent,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_current_pin, labelText: l10n.s_current_pin,
errorText: _currentIsWrong ? _currentPinError : null, errorText: _currentIsWrong ? _currentPinError : null,
errorMaxLines: 3, errorMaxLines: 3,
prefixIcon: const Icon(Icons.pin_outlined), prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureCurrent
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureCurrent = !_isObscureCurrent;
});
},
tooltip:
_isObscureCurrent ? l10n.s_show_pin : l10n.s_hide_pin,
),
), ),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@ -98,15 +114,25 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
AppTextFormField( AppTextFormField(
initialValue: _newPin, initialValue: _newPin,
autofocus: !hasPin, autofocus: !hasPin,
obscureText: true, obscureText: _isObscureNew,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_new_pin, labelText: l10n.s_new_pin,
enabled: !hasPin || _currentPin.isNotEmpty, enabled: !hasPin || _currentPin.isNotEmpty,
errorText: _newIsWrong ? _newPinError : null, errorText: _newIsWrong ? _newPinError : null,
errorMaxLines: 3, errorMaxLines: 3,
prefixIcon: const Icon(Icons.pin_outlined), prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscureNew ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureNew = !_isObscureNew;
});
},
tooltip: _isObscureNew ? l10n.s_show_pin : l10n.s_hide_pin,
),
), ),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@ -117,12 +143,24 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
), ),
AppTextFormField( AppTextFormField(
initialValue: _confirmPin, initialValue: _confirmPin,
obscureText: true, obscureText: _isObscureConfirm,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_confirm_pin, labelText: l10n.s_confirm_pin,
prefixIcon: const Icon(Icons.pin_outlined), prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureConfirm
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureConfirm = !_isObscureConfirm;
});
},
tooltip:
_isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin,
),
enabled: enabled:
(!hasPin || _currentPin.isNotEmpty) && _newPin.isNotEmpty, (!hasPin || _currentPin.isNotEmpty) && _newPin.isNotEmpty,
), ),

View File

@ -21,6 +21,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart'; import '../../widgets/app_text_form_field.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_utils.dart'; import '../../widgets/utf8_utils.dart';
@ -95,7 +96,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameFingerprintDialog> {
maxLength: 15, maxLength: 15,
inputFormatters: [limitBytesLength(15)], inputFormatters: [limitBytesLength(15)],
buildCounter: buildByteCounterFor(_label), buildCounter: buildByteCounterFor(_label),
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_label, labelText: l10n.s_label,
prefixIcon: const Icon(Icons.fingerprint_outlined), prefixIcon: const Icon(Icons.fingerprint_outlined),

View File

@ -43,64 +43,76 @@ class ResetDialog extends ConsumerStatefulWidget {
class _ResetDialogState extends ConsumerState<ResetDialog> { class _ResetDialogState extends ConsumerState<ResetDialog> {
StreamSubscription<InteractionEvent>? _subscription; StreamSubscription<InteractionEvent>? _subscription;
InteractionEvent? _interaction; InteractionEvent? _interaction;
int _currentStep = -1;
final _totalSteps = 3;
String _getMessage() { String _getMessage() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final nfc = widget.node.transport == Transport.nfc; final nfc = widget.node.transport == Transport.nfc;
if (_currentStep == 3) {
return l10n.l_fido_app_reset;
}
return switch (_interaction) { return switch (_interaction) {
InteractionEvent.remove => InteractionEvent.remove =>
nfc ? l10n.l_remove_yk_from_reader : l10n.l_unplug_yk, nfc ? l10n.l_remove_yk_from_reader : l10n.l_unplug_yk,
InteractionEvent.insert => InteractionEvent.insert =>
nfc ? l10n.l_replace_yk_on_reader : l10n.l_reinsert_yk, nfc ? l10n.l_replace_yk_on_reader : l10n.l_reinsert_yk,
InteractionEvent.touch => l10n.l_touch_button_now, InteractionEvent.touch => l10n.l_touch_button_now,
null => l10n.l_press_reset_to_begin null => ''
}; };
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
double progress = _currentStep == -1 ? 0.0 : _currentStep / (_totalSteps);
return ResponsiveDialog( return ResponsiveDialog(
title: Text(l10n.s_factory_reset), title: Text(l10n.s_factory_reset),
onCancel: () { onCancel: _currentStep < 3
_subscription?.cancel(); ? () {
}, _subscription?.cancel();
actions: [ }
TextButton( : null,
onPressed: _subscription == null actions: _currentStep < 3
? () async { ? [
_subscription = ref TextButton(
.read(fidoStateProvider(widget.node.path).notifier) onPressed: _subscription == null
.reset() ? () async {
.listen((event) { _subscription = ref
setState(() { .read(fidoStateProvider(widget.node.path).notifier)
_interaction = event; .reset()
}); .listen((event) {
}, onDone: () { setState(() {
_subscription = null; _currentStep++;
Navigator.of(context).pop(); _interaction = event;
showMessage(context, l10n.l_fido_app_reset); });
}, onError: (e) { }, onDone: () {
_log.error('Error performing FIDO reset', e); setState(() {
Navigator.of(context).pop(); _currentStep++;
final String errorMessage; });
// TODO: Make this cleaner than importing desktop specific RpcError. _subscription = null;
if (e is RpcError) { }, onError: (e) {
errorMessage = e.message; _log.error('Error performing FIDO reset', e);
} else { Navigator.of(context).pop();
errorMessage = e.toString(); final String errorMessage;
} // TODO: Make this cleaner than importing desktop specific RpcError.
showMessage( if (e is RpcError) {
context, errorMessage = e.message;
l10n.l_reset_failed(errorMessage), } else {
duration: const Duration(seconds: 4), errorMessage = e.toString();
); }
}); showMessage(
} context,
: null, l10n.l_reset_failed(errorMessage),
child: Text(l10n.s_reset), duration: const Duration(seconds: 4),
), );
], });
}
: null,
child: Text(l10n.s_reset),
),
]
: [],
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0), padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column( child: Column(
@ -113,10 +125,10 @@ class _ResetDialogState extends ConsumerState<ResetDialog> {
Text( Text(
l10n.p_warning_disable_accounts, l10n.p_warning_disable_accounts,
), ),
Center( if (_currentStep > -1) ...[
child: Text(_getMessage(), Text('${l10n.s_status}: ${_getMessage()}'),
style: Theme.of(context).textTheme.titleLarge), LinearProgressIndicator(value: progress)
), ],
] ]
.map((e) => Padding( .map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -29,6 +29,7 @@
"s_close": "Schließen", "s_close": "Schließen",
"s_delete": "Löschen", "s_delete": "Löschen",
"s_quit": "Beenden", "s_quit": "Beenden",
"s_status": null,
"s_unlock": "Entsperren", "s_unlock": "Entsperren",
"s_calculate": "Berechnen", "s_calculate": "Berechnen",
"s_import": null, "s_import": null,
@ -61,8 +62,9 @@
"s_manage": "Verwalten", "s_manage": "Verwalten",
"s_setup": "Einrichten", "s_setup": "Einrichten",
"s_settings": "Einstellungen", "s_settings": "Einstellungen",
"s_piv": null, "s_certificates": null,
"s_webauthn": "WebAuthn", "s_webauthn": "WebAuthn",
"s_slots": null,
"s_help_and_about": "Hilfe und Über", "s_help_and_about": "Hilfe und Über",
"s_help_and_feedback": "Hilfe und Feedback", "s_help_and_feedback": "Hilfe und Feedback",
"s_send_feedback": "Senden Sie uns Feedback", "s_send_feedback": "Senden Sie uns Feedback",
@ -78,6 +80,14 @@
"s_hide_secret_key": null, "s_hide_secret_key": null,
"s_private_key": null, "s_private_key": null,
"s_invalid_length": "Ungültige Länge", "s_invalid_length": "Ungültige Länge",
"s_invalid_format": null,
"l_invalid_format_allowed_chars": null,
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": null,
"s_require_touch": "Berührung ist erforderlich", "s_require_touch": "Berührung ist erforderlich",
"q_have_account_info": "Haben Sie Konto-Informationen?", "q_have_account_info": "Haben Sie Konto-Informationen?",
"s_run_diagnostics": "Diagnose ausführen", "s_run_diagnostics": "Diagnose ausführen",
@ -189,6 +199,8 @@
"s_change_puk": null, "s_change_puk": null,
"s_show_pin": null, "s_show_pin": null,
"s_hide_pin": null, "s_hide_pin": null,
"s_show_puk": null,
"s_hide_puk": null,
"s_current_pin": "Derzeitige PIN", "s_current_pin": "Derzeitige PIN",
"s_current_puk": null, "s_current_puk": null,
"s_new_pin": "Neue PIN", "s_new_pin": "Neue PIN",
@ -286,6 +298,8 @@
"s_management_key": null, "s_management_key": null,
"s_current_management_key": null, "s_current_management_key": null,
"s_new_management_key": null, "s_new_management_key": null,
"s_show_management_key": null,
"s_hide_management_key": null,
"l_change_management_key": null, "l_change_management_key": null,
"p_change_management_key_desc": null, "p_change_management_key_desc": null,
"l_management_key_changed": null, "l_management_key_changed": null,
@ -429,7 +443,6 @@
"@_certificates": {}, "@_certificates": {},
"s_certificate": null, "s_certificate": null,
"s_certificates": null,
"s_csr": null, "s_csr": null,
"s_subject": null, "s_subject": null,
"l_export_csr_file": null, "l_export_csr_file": null,
@ -504,6 +517,74 @@
"s_slot_9d": null, "s_slot_9d": null,
"s_slot_9e": null, "s_slot_9e": null,
"@_otp_slots": {},
"s_otp_slot_one": null,
"s_otp_slot_two": null,
"l_otp_slot_empty": null,
"l_otp_slot_configured": null,
"@_otp_slot_configurations": {},
"s_yubiotp": null,
"l_yubiotp_desc": null,
"s_challenge_response": null,
"l_challenge_response_desc": null,
"s_static_password": null,
"l_static_password_desc": null,
"s_hotp": null,
"l_hotp_desc": null,
"s_public_id": null,
"s_private_id": null,
"s_allow_any_character": null,
"s_use_serial": null,
"s_generate_private_id": null,
"s_generate_secret_key": null,
"s_generate_passowrd": null,
"l_select_file": null,
"l_no_export_file": null,
"s_no_export": null,
"s_export": null,
"l_export_configuration_file": null,
"@_otp_slot_actions": {},
"s_delete_slot": null,
"l_delete_slot_desc": null,
"p_warning_delete_slot_configuration": null,
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": null,
"s_swap": null,
"s_swap_slots": null,
"l_swap_slots_desc": null,
"p_swap_slots_desc": null,
"l_slots_swapped": null,
"l_slot_credential_configured": null,
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": null,
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": null,
"l_append_enter_desc": null,
"@_otp_errors": {},
"p_otp_slot_configuration_error": null,
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {}, "@_permissions": {},
"s_enable_nfc": "NFC aktivieren", "s_enable_nfc": "NFC aktivieren",
"s_permission_denied": "Zugriff verweigert", "s_permission_denied": "Zugriff verweigert",
@ -539,7 +620,6 @@
"l_oath_application_reset": "OATH Anwendung zurücksetzen", "l_oath_application_reset": "OATH Anwendung zurücksetzen",
"s_reset_fido": "FIDO zurücksetzen", "s_reset_fido": "FIDO zurücksetzen",
"l_fido_app_reset": "FIDO Anwendung zurückgesetzt", "l_fido_app_reset": "FIDO Anwendung zurückgesetzt",
"l_press_reset_to_begin": "Drücken Sie Zurücksetzen um zu beginnen\u2026",
"l_reset_failed": "Fehler beim Zurücksetzen: {message}", "l_reset_failed": "Fehler beim Zurücksetzen: {message}",
"@l_reset_failed": { "@l_reset_failed": {
"placeholders": { "placeholders": {

View File

@ -29,6 +29,7 @@
"s_close": "Close", "s_close": "Close",
"s_delete": "Delete", "s_delete": "Delete",
"s_quit": "Quit", "s_quit": "Quit",
"s_status": "Status",
"s_unlock": "Unlock", "s_unlock": "Unlock",
"s_calculate": "Calculate", "s_calculate": "Calculate",
"s_import": "Import", "s_import": "Import",
@ -61,8 +62,9 @@
"s_manage": "Manage", "s_manage": "Manage",
"s_setup": "Setup", "s_setup": "Setup",
"s_settings": "Settings", "s_settings": "Settings",
"s_piv": "PIV", "s_certificates": "Certificates",
"s_webauthn": "WebAuthn", "s_webauthn": "WebAuthn",
"s_slots": "Slots",
"s_help_and_about": "Help and about", "s_help_and_about": "Help and about",
"s_help_and_feedback": "Help and feedback", "s_help_and_feedback": "Help and feedback",
"s_send_feedback": "Send us feedback", "s_send_feedback": "Send us feedback",
@ -78,6 +80,14 @@
"s_hide_secret_key": "Hide secret key", "s_hide_secret_key": "Hide secret key",
"s_private_key": "Private key", "s_private_key": "Private key",
"s_invalid_length": "Invalid length", "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", "s_require_touch": "Require touch",
"q_have_account_info": "Have account info?", "q_have_account_info": "Have account info?",
"s_run_diagnostics": "Run diagnostics", "s_run_diagnostics": "Run diagnostics",
@ -189,6 +199,8 @@
"s_change_puk": "Change PUK", "s_change_puk": "Change PUK",
"s_show_pin": "Show PIN", "s_show_pin": "Show PIN",
"s_hide_pin": "Hide PIN", "s_hide_pin": "Hide PIN",
"s_show_puk": "Show PUK",
"s_hide_puk": "Hide PUK",
"s_current_pin": "Current PIN", "s_current_pin": "Current PIN",
"s_current_puk": "Current PUK", "s_current_puk": "Current PUK",
"s_new_pin": "New PIN", "s_new_pin": "New PIN",
@ -286,6 +298,8 @@
"s_management_key": "Management key", "s_management_key": "Management key",
"s_current_management_key": "Current management key", "s_current_management_key": "Current management key",
"s_new_management_key": "New management key", "s_new_management_key": "New management key",
"s_show_management_key": "Show management key",
"s_hide_management_key": "Hide management key",
"l_change_management_key": "Change management key", "l_change_management_key": "Change management key",
"p_change_management_key_desc": "Change your management key. You can optionally choose to allow the PIN to be used instead of the management key.", "p_change_management_key_desc": "Change your management key. You can optionally choose to allow the PIN to be used instead of the management key.",
"l_management_key_changed": "Management key changed", "l_management_key_changed": "Management key changed",
@ -429,7 +443,6 @@
"@_certificates": {}, "@_certificates": {},
"s_certificate": "Certificate", "s_certificate": "Certificate",
"s_certificates": "Certificates",
"s_csr": "CSR", "s_csr": "CSR",
"s_subject": "Subject", "s_subject": "Subject",
"l_export_csr_file": "Save CSR to file", "l_export_csr_file": "Save CSR to file",
@ -504,6 +517,74 @@
"s_slot_9d": "Key Management", "s_slot_9d": "Key Management",
"s_slot_9e": "Card Authentication", "s_slot_9e": "Card Authentication",
"@_otp_slots": {},
"s_otp_slot_one": "Short touch",
"s_otp_slot_two": "Long touch",
"l_otp_slot_empty": "Slot is empty",
"l_otp_slot_configured": "Slot is configured",
"@_otp_slot_configurations": {},
"s_yubiotp": "Yubico OTP",
"l_yubiotp_desc": "Program a Yubico OTP credential",
"s_challenge_response": "Challenge-response",
"l_challenge_response_desc": "Program a challenge-response credential",
"s_static_password": "Static password",
"l_static_password_desc": "Configure a static password",
"s_hotp": "OATH-HOTP",
"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",
"s_use_serial": "Use serial",
"s_generate_private_id": "Generate private ID",
"s_generate_secret_key": "Generate secret key",
"s_generate_passowrd": "Generate password",
"l_select_file": "Select file",
"l_no_export_file": "No export file",
"s_no_export": "No export",
"s_export": "Export",
"l_export_configuration_file": "Export configuration to file",
"@_otp_slot_actions": {},
"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_id": {}
}
},
"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_credential_configured": "Configured {type} credential",
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"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 ⏎",
"l_append_enter_desc": "Append an Enter keystroke after emitting the OTP",
"@_otp_errors": {},
"p_otp_slot_configuration_error": "Failed to modify {slot}! Make sure the YubiKey does not have restrictive access.",
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {}, "@_permissions": {},
"s_enable_nfc": "Enable NFC", "s_enable_nfc": "Enable NFC",
"s_permission_denied": "Permission denied", "s_permission_denied": "Permission denied",
@ -539,7 +620,6 @@
"l_oath_application_reset": "OATH application reset", "l_oath_application_reset": "OATH application reset",
"s_reset_fido": "Reset FIDO", "s_reset_fido": "Reset FIDO",
"l_fido_app_reset": "FIDO application reset", "l_fido_app_reset": "FIDO application reset",
"l_press_reset_to_begin": "Press reset to begin\u2026",
"l_reset_failed": "Error performing reset: {message}", "l_reset_failed": "Error performing reset: {message}",
"@l_reset_failed": { "@l_reset_failed": {
"placeholders": { "placeholders": {

View File

@ -29,6 +29,7 @@
"s_close": "Fermer", "s_close": "Fermer",
"s_delete": "Supprimer", "s_delete": "Supprimer",
"s_quit": "Quitter", "s_quit": "Quitter",
"s_status": null,
"s_unlock": "Déverrouiller", "s_unlock": "Déverrouiller",
"s_calculate": "Calculer", "s_calculate": "Calculer",
"s_import": "Importer", "s_import": "Importer",
@ -61,8 +62,9 @@
"s_manage": "Gérer", "s_manage": "Gérer",
"s_setup": "Configuration", "s_setup": "Configuration",
"s_settings": "Paramètres", "s_settings": "Paramètres",
"s_piv": "PIV", "s_certificates": "Certificats",
"s_webauthn": "WebAuthn", "s_webauthn": "WebAuthn",
"s_slots": null,
"s_help_and_about": "Aide et à propos", "s_help_and_about": "Aide et à propos",
"s_help_and_feedback": "Aide et retours", "s_help_and_feedback": "Aide et retours",
"s_send_feedback": "Envoyer nous un retour", "s_send_feedback": "Envoyer nous un retour",
@ -78,6 +80,14 @@
"s_hide_secret_key": null, "s_hide_secret_key": null,
"s_private_key": "Clé privée", "s_private_key": "Clé privée",
"s_invalid_length": "Longueur invalide", "s_invalid_length": "Longueur invalide",
"s_invalid_format": null,
"l_invalid_format_allowed_chars": null,
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": null,
"s_require_touch": "Touché requis", "s_require_touch": "Touché requis",
"q_have_account_info": "Avez-vous des informations de compte?", "q_have_account_info": "Avez-vous des informations de compte?",
"s_run_diagnostics": "Exécuter un diagnostique", "s_run_diagnostics": "Exécuter un diagnostique",
@ -189,6 +199,8 @@
"s_change_puk": "Changez PUK", "s_change_puk": "Changez PUK",
"s_show_pin": null, "s_show_pin": null,
"s_hide_pin": null, "s_hide_pin": null,
"s_show_puk": null,
"s_hide_puk": null,
"s_current_pin": "PIN actuel", "s_current_pin": "PIN actuel",
"s_current_puk": "PUK actuel", "s_current_puk": "PUK actuel",
"s_new_pin": "Nouveau PIN", "s_new_pin": "Nouveau PIN",
@ -286,6 +298,8 @@
"s_management_key": "Gestion des clés", "s_management_key": "Gestion des clés",
"s_current_management_key": "Clé actuelle de gestion", "s_current_management_key": "Clé actuelle de gestion",
"s_new_management_key": "Nouvelle clé de gestion", "s_new_management_key": "Nouvelle clé de gestion",
"s_show_management_key": null,
"s_hide_management_key": null,
"l_change_management_key": "Changer la clé de gestion", "l_change_management_key": "Changer la clé de gestion",
"p_change_management_key_desc": "Changer votre clé de gestion. Vous pouvez optionnellement autoriser le PIN à être utilisé à la place de la clé de gestion.", "p_change_management_key_desc": "Changer votre clé de gestion. Vous pouvez optionnellement autoriser le PIN à être utilisé à la place de la clé de gestion.",
"l_management_key_changed": "Ché de gestion changée", "l_management_key_changed": "Ché de gestion changée",
@ -429,7 +443,6 @@
"@_certificates": {}, "@_certificates": {},
"s_certificate": "Certificat", "s_certificate": "Certificat",
"s_certificates": "Certificats",
"s_csr": "CSR", "s_csr": "CSR",
"s_subject": "Sujet", "s_subject": "Sujet",
"l_export_csr_file": "Sauvegarder le CSR vers un fichier", "l_export_csr_file": "Sauvegarder le CSR vers un fichier",
@ -504,6 +517,74 @@
"s_slot_9d": "Gestion des clés", "s_slot_9d": "Gestion des clés",
"s_slot_9e": "Authentification par carte", "s_slot_9e": "Authentification par carte",
"@_otp_slots": {},
"s_otp_slot_one": null,
"s_otp_slot_two": null,
"l_otp_slot_empty": null,
"l_otp_slot_configured": null,
"@_otp_slot_configurations": {},
"s_yubiotp": null,
"l_yubiotp_desc": null,
"s_challenge_response": null,
"l_challenge_response_desc": null,
"s_static_password": null,
"l_static_password_desc": null,
"s_hotp": null,
"l_hotp_desc": null,
"s_public_id": null,
"s_private_id": null,
"s_allow_any_character": null,
"s_use_serial": null,
"s_generate_private_id": null,
"s_generate_secret_key": null,
"s_generate_passowrd": null,
"l_select_file": null,
"l_no_export_file": null,
"s_no_export": null,
"s_export": null,
"l_export_configuration_file": null,
"@_otp_slot_actions": {},
"s_delete_slot": null,
"l_delete_slot_desc": null,
"p_warning_delete_slot_configuration": null,
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": null,
"s_swap": null,
"s_swap_slots": null,
"l_swap_slots_desc": null,
"p_swap_slots_desc": null,
"l_slots_swapped": null,
"l_slot_credential_configured": null,
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": null,
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": null,
"l_append_enter_desc": null,
"@_otp_errors": {},
"p_otp_slot_configuration_error": null,
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {}, "@_permissions": {},
"s_enable_nfc": "Activer le NFC", "s_enable_nfc": "Activer le NFC",
"s_permission_denied": "Permission refusée", "s_permission_denied": "Permission refusée",
@ -539,7 +620,6 @@
"l_oath_application_reset": "L'application OATH à été réinitialisée", "l_oath_application_reset": "L'application OATH à été réinitialisée",
"s_reset_fido": "Réinitialiser le FIDO", "s_reset_fido": "Réinitialiser le FIDO",
"l_fido_app_reset": "L'application FIDO à été réinitialisée", "l_fido_app_reset": "L'application FIDO à été réinitialisée",
"l_press_reset_to_begin": "Appuyez sur réinitialiser pour commencer\u2026",
"l_reset_failed": "Erreur pendant la réinitialisation: {message}", "l_reset_failed": "Erreur pendant la réinitialisation: {message}",
"@l_reset_failed": { "@l_reset_failed": {
"placeholders": { "placeholders": {

View File

@ -29,6 +29,7 @@
"s_close": "閉じる", "s_close": "閉じる",
"s_delete": "消去", "s_delete": "消去",
"s_quit": "終了", "s_quit": "終了",
"s_status": null,
"s_unlock": "ロック解除", "s_unlock": "ロック解除",
"s_calculate": "計算", "s_calculate": "計算",
"s_import": "インポート", "s_import": "インポート",
@ -61,8 +62,9 @@
"s_manage": "管理", "s_manage": "管理",
"s_setup": "セットアップ", "s_setup": "セットアップ",
"s_settings": "設定", "s_settings": "設定",
"s_piv": "PIV", "s_certificates": "証明書",
"s_webauthn": "WebAuthn", "s_webauthn": "WebAuthn",
"s_slots": null,
"s_help_and_about": "ヘルプと概要", "s_help_and_about": "ヘルプと概要",
"s_help_and_feedback": "ヘルプとフィードバック", "s_help_and_feedback": "ヘルプとフィードバック",
"s_send_feedback": "フィードバックの送信", "s_send_feedback": "フィードバックの送信",
@ -78,6 +80,14 @@
"s_hide_secret_key": null, "s_hide_secret_key": null,
"s_private_key": "秘密鍵", "s_private_key": "秘密鍵",
"s_invalid_length": "無効な長さです", "s_invalid_length": "無効な長さです",
"s_invalid_format": null,
"l_invalid_format_allowed_chars": null,
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": null,
"s_require_touch": "タッチが必要", "s_require_touch": "タッチが必要",
"q_have_account_info": "アカウント情報をお持ちですか?", "q_have_account_info": "アカウント情報をお持ちですか?",
"s_run_diagnostics": "診断を実行する", "s_run_diagnostics": "診断を実行する",
@ -189,6 +199,8 @@
"s_change_puk": "PUKを変更する", "s_change_puk": "PUKを変更する",
"s_show_pin": null, "s_show_pin": null,
"s_hide_pin": null, "s_hide_pin": null,
"s_show_puk": null,
"s_hide_puk": null,
"s_current_pin": "現在のPIN", "s_current_pin": "現在のPIN",
"s_current_puk": "現在のPUK", "s_current_puk": "現在のPUK",
"s_new_pin": "新しいPIN", "s_new_pin": "新しいPIN",
@ -286,6 +298,8 @@
"s_management_key": "Management key", "s_management_key": "Management key",
"s_current_management_key": "現在のManagement key", "s_current_management_key": "現在のManagement key",
"s_new_management_key": "新しいManagement key", "s_new_management_key": "新しいManagement key",
"s_show_management_key": null,
"s_hide_management_key": null,
"l_change_management_key": "Management keyの変更", "l_change_management_key": "Management keyの変更",
"p_change_management_key_desc": "Management keyを変更してください。Management keyの代わりにPINを使用することも可能です", "p_change_management_key_desc": "Management keyを変更してください。Management keyの代わりにPINを使用することも可能です",
"l_management_key_changed": "Management keyは変更されました", "l_management_key_changed": "Management keyは変更されました",
@ -429,7 +443,6 @@
"@_certificates": {}, "@_certificates": {},
"s_certificate": "証明書", "s_certificate": "証明書",
"s_certificates": "証明書",
"s_csr": "CSR", "s_csr": "CSR",
"s_subject": "サブジェクト", "s_subject": "サブジェクト",
"l_export_csr_file": "CSRをファイルに保存", "l_export_csr_file": "CSRをファイルに保存",
@ -504,6 +517,74 @@
"s_slot_9d": "鍵の管理", "s_slot_9d": "鍵の管理",
"s_slot_9e": "カード認証", "s_slot_9e": "カード認証",
"@_otp_slots": {},
"s_otp_slot_one": null,
"s_otp_slot_two": null,
"l_otp_slot_empty": null,
"l_otp_slot_configured": null,
"@_otp_slot_configurations": {},
"s_yubiotp": null,
"l_yubiotp_desc": null,
"s_challenge_response": null,
"l_challenge_response_desc": null,
"s_static_password": null,
"l_static_password_desc": null,
"s_hotp": null,
"l_hotp_desc": null,
"s_public_id": null,
"s_private_id": null,
"s_allow_any_character": null,
"s_use_serial": null,
"s_generate_private_id": null,
"s_generate_secret_key": null,
"s_generate_passowrd": null,
"l_select_file": null,
"l_no_export_file": null,
"s_no_export": null,
"s_export": null,
"l_export_configuration_file": null,
"@_otp_slot_actions": {},
"s_delete_slot": null,
"l_delete_slot_desc": null,
"p_warning_delete_slot_configuration": null,
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": null,
"s_swap": null,
"s_swap_slots": null,
"l_swap_slots_desc": null,
"p_swap_slots_desc": null,
"l_slots_swapped": null,
"l_slot_credential_configured": null,
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": null,
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": null,
"l_append_enter_desc": null,
"@_otp_errors": {},
"p_otp_slot_configuration_error": null,
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {}, "@_permissions": {},
"s_enable_nfc": "NFCを有効にする", "s_enable_nfc": "NFCを有効にする",
"s_permission_denied": "権限がありません", "s_permission_denied": "権限がありません",
@ -539,7 +620,6 @@
"l_oath_application_reset": "OATHアプリケーションのリセット", "l_oath_application_reset": "OATHアプリケーションのリセット",
"s_reset_fido": "FIDOのリセット", "s_reset_fido": "FIDOのリセット",
"l_fido_app_reset": "FIDOアプリケーションのリセット", "l_fido_app_reset": "FIDOアプリケーションのリセット",
"l_press_reset_to_begin": "リセットを押して開始してください\u2026",
"l_reset_failed": "リセット実行中のエラー:{message}", "l_reset_failed": "リセット実行中のエラー:{message}",
"@l_reset_failed": { "@l_reset_failed": {
"placeholders": { "placeholders": {

View File

@ -29,6 +29,7 @@
"s_close": "Zamknij", "s_close": "Zamknij",
"s_delete": "Usuń", "s_delete": "Usuń",
"s_quit": "Wyjdź", "s_quit": "Wyjdź",
"s_status": null,
"s_unlock": "Odblokuj", "s_unlock": "Odblokuj",
"s_calculate": "Oblicz", "s_calculate": "Oblicz",
"s_import": "Importuj", "s_import": "Importuj",
@ -61,8 +62,9 @@
"s_manage": "Zarządzaj", "s_manage": "Zarządzaj",
"s_setup": "Konfiguruj", "s_setup": "Konfiguruj",
"s_settings": "Ustawienia", "s_settings": "Ustawienia",
"s_piv": "PIV", "s_certificates": "Certyfikaty",
"s_webauthn": "WebAuthn", "s_webauthn": "WebAuthn",
"s_slots": null,
"s_help_and_about": "Pomoc i informacje", "s_help_and_about": "Pomoc i informacje",
"s_help_and_feedback": "Pomoc i opinie", "s_help_and_feedback": "Pomoc i opinie",
"s_send_feedback": "Prześlij opinię", "s_send_feedback": "Prześlij opinię",
@ -78,6 +80,14 @@
"s_hide_secret_key": "Ukryj tajny klucz", "s_hide_secret_key": "Ukryj tajny klucz",
"s_private_key": "Klucz prywatny", "s_private_key": "Klucz prywatny",
"s_invalid_length": "Nieprawidłowa długość", "s_invalid_length": "Nieprawidłowa długość",
"s_invalid_format": null,
"l_invalid_format_allowed_chars": null,
"@l_invalid_format_allowed_chars": {
"placeholders": {
"characters": {}
}
},
"l_invalid_keyboard_character": null,
"s_require_touch": "Wymagaj dotknięcia", "s_require_touch": "Wymagaj dotknięcia",
"q_have_account_info": "Masz dane konta?", "q_have_account_info": "Masz dane konta?",
"s_run_diagnostics": "Uruchom diagnostykę", "s_run_diagnostics": "Uruchom diagnostykę",
@ -189,6 +199,8 @@
"s_change_puk": "Zmień PUK", "s_change_puk": "Zmień PUK",
"s_show_pin": "Pokaż PIN", "s_show_pin": "Pokaż PIN",
"s_hide_pin": "Ukryj PIN", "s_hide_pin": "Ukryj PIN",
"s_show_puk": null,
"s_hide_puk": null,
"s_current_pin": "Aktualny PIN", "s_current_pin": "Aktualny PIN",
"s_current_puk": "Aktualny PUK", "s_current_puk": "Aktualny PUK",
"s_new_pin": "Nowy PIN", "s_new_pin": "Nowy PIN",
@ -286,6 +298,8 @@
"s_management_key": "Klucz zarządzania", "s_management_key": "Klucz zarządzania",
"s_current_management_key": "Aktualny klucz zarządzania", "s_current_management_key": "Aktualny klucz zarządzania",
"s_new_management_key": "Nowy klucz zarządzania", "s_new_management_key": "Nowy klucz zarządzania",
"s_show_management_key": null,
"s_hide_management_key": null,
"l_change_management_key": "Zmień klucz zarządzania", "l_change_management_key": "Zmień klucz zarządzania",
"p_change_management_key_desc": "Zmień swój klucz zarządzania. Opcjonalnie możesz zezwolić na używanie kodu PIN zamiast klucza zarządzania.", "p_change_management_key_desc": "Zmień swój klucz zarządzania. Opcjonalnie możesz zezwolić na używanie kodu PIN zamiast klucza zarządzania.",
"l_management_key_changed": "Zmieniono klucz zarządzania", "l_management_key_changed": "Zmieniono klucz zarządzania",
@ -429,7 +443,6 @@
"@_certificates": {}, "@_certificates": {},
"s_certificate": "Certyfikat", "s_certificate": "Certyfikat",
"s_certificates": "Certyfikaty",
"s_csr": "CSR", "s_csr": "CSR",
"s_subject": "Temat", "s_subject": "Temat",
"l_export_csr_file": "Zapisz CSR do pliku", "l_export_csr_file": "Zapisz CSR do pliku",
@ -504,6 +517,74 @@
"s_slot_9d": "Menedżer kluczy", "s_slot_9d": "Menedżer kluczy",
"s_slot_9e": "Autoryzacja karty", "s_slot_9e": "Autoryzacja karty",
"@_otp_slots": {},
"s_otp_slot_one": null,
"s_otp_slot_two": null,
"l_otp_slot_empty": null,
"l_otp_slot_configured": null,
"@_otp_slot_configurations": {},
"s_yubiotp": null,
"l_yubiotp_desc": null,
"s_challenge_response": null,
"l_challenge_response_desc": null,
"s_static_password": null,
"l_static_password_desc": null,
"s_hotp": null,
"l_hotp_desc": null,
"s_public_id": null,
"s_private_id": null,
"s_allow_any_character": null,
"s_use_serial": null,
"s_generate_private_id": null,
"s_generate_secret_key": null,
"s_generate_passowrd": null,
"l_select_file": null,
"l_no_export_file": null,
"s_no_export": null,
"s_export": null,
"l_export_configuration_file": null,
"@_otp_slot_actions": {},
"s_delete_slot": null,
"l_delete_slot_desc": null,
"p_warning_delete_slot_configuration": null,
"@p_warning_delete_slot_configuration": {
"placeholders": {
"slot_id": {}
}
},
"l_slot_deleted": null,
"s_swap": null,
"s_swap_slots": null,
"l_swap_slots_desc": null,
"p_swap_slots_desc": null,
"l_slots_swapped": null,
"l_slot_credential_configured": null,
"@l_slot_credential_configured": {
"placeholders": {
"type": {}
}
},
"l_slot_credential_configured_and_exported": null,
"@l_slot_credential_configured_and_exported": {
"placeholders": {
"type": {},
"file": {}
}
},
"s_append_enter": null,
"l_append_enter_desc": null,
"@_otp_errors": {},
"p_otp_slot_configuration_error": null,
"@p_otp_slot_configuration_error": {
"placeholders": {
"slot": {}
}
},
"@_permissions": {}, "@_permissions": {},
"s_enable_nfc": "Włącz NFC", "s_enable_nfc": "Włącz NFC",
"s_permission_denied": "Odmowa dostępu", "s_permission_denied": "Odmowa dostępu",
@ -539,7 +620,6 @@
"l_oath_application_reset": "Reset funkcji OATH", "l_oath_application_reset": "Reset funkcji OATH",
"s_reset_fido": "Zresetuj FIDO", "s_reset_fido": "Zresetuj FIDO",
"l_fido_app_reset": "Reset funkcji FIDO", "l_fido_app_reset": "Reset funkcji FIDO",
"l_press_reset_to_begin": "Naciśnij reset, aby rozpocząć\u2026",
"l_reset_failed": "Błąd podczas resetowania: {message}", "l_reset_failed": "Błąd podczas resetowania: {message}",
"@l_reset_failed": { "@l_reset_failed": {
"placeholders": { "placeholders": {

View File

@ -19,7 +19,6 @@ import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -30,11 +29,13 @@ import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/views/user_interaction.dart'; import '../../app/views/user_interaction.dart';
import '../../core/models.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../exception/apdu_exception.dart'; import '../../exception/apdu_exception.dart';
import '../../exception/cancellation_exception.dart'; import '../../exception/cancellation_exception.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart'; import '../../widgets/choice_filter_chip.dart';
import '../../widgets/file_drop_target.dart'; import '../../widgets/file_drop_target.dart';
@ -49,9 +50,6 @@ import 'utils.dart';
final _log = Logger('oath.view.add_account_page'); final _log = Logger('oath.view.add_account_page');
final _secretFormatterPattern =
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
class OathAddAccountPage extends ConsumerStatefulWidget { class OathAddAccountPage extends ConsumerStatefulWidget {
final DevicePath? devicePath; final DevicePath? devicePath;
final OathState? state; final OathState? state;
@ -83,7 +81,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm; HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
int _digits = defaultDigits; int _digits = defaultDigits;
int _counter = defaultCounter; int _counter = defaultCounter;
bool _validateSecretLength = false; bool _validateSecret = false;
bool _dataLoaded = false; bool _dataLoaded = false;
bool _isObscure = true; bool _isObscure = true;
List<int> _periodValues = [20, 30, 45, 60]; List<int> _periodValues = [20, 30, 45, 60];
@ -235,6 +233,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
final secret = _secretController.text.replaceAll(' ', ''); final secret = _secretController.text.replaceAll(' ', '');
final secretLengthValid = secret.length * 5 % 8 < 5; final secretLengthValid = secret.length * 5 % 8 < 5;
final secretFormatValid = Format.base32.isValid(secret);
// is this credentials name/issuer pair different from all other? // is this credentials name/issuer pair different from all other?
final isUnique = _credentials final isUnique = _credentials
@ -271,7 +270,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
} }
void submit() async { void submit() async {
if (secretLengthValid) { if (secretLengthValid && secretFormatValid) {
final cred = CredentialData( final cred = CredentialData(
issuer: issuerText.isEmpty ? null : issuerText, issuer: issuerText.isEmpty ? null : issuerText,
name: nameText, name: nameText,
@ -304,7 +303,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
} }
} else { } else {
setState(() { setState(() {
_validateSecretLength = true; _validateSecret = true;
}); });
} }
} }
@ -365,17 +364,17 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
limitBytesLength(issuerRemaining), limitBytesLength(issuerRemaining),
], ],
buildCounter: buildByteCounterFor(issuerText), buildCounter: buildByteCounterFor(issuerText),
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_issuer_optional, labelText: l10n.s_issuer_optional,
helperText: '', helperText:
// Prevents dialog resizing when disabled '', // Prevents dialog resizing when disabled
prefixIcon: const Icon(Icons.business_outlined),
errorText: (byteLength(issuerText) > issuerMaxLength) errorText: (byteLength(issuerText) > issuerMaxLength)
? '' // needs empty string to render as error ? '' // needs empty string to render as error
: issuerNoColon : issuerNoColon
? null ? null
: l10n.l_invalid_character_issuer, : l10n.l_invalid_character_issuer,
prefixIcon: const Icon(Icons.business_outlined),
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
@ -393,9 +392,8 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
maxLength: nameMaxLength, maxLength: nameMaxLength,
buildCounter: buildByteCounterFor(nameText), buildCounter: buildByteCounterFor(nameText),
inputFormatters: [limitBytesLength(nameRemaining)], inputFormatters: [limitBytesLength(nameRemaining)],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
labelText: l10n.s_account_name, labelText: l10n.s_account_name,
helperText: '', helperText: '',
// Prevents dialog resizing when disabled // Prevents dialog resizing when disabled
@ -404,6 +402,11 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
: isUnique : isUnique
? null ? null
: l10n.l_name_already_exists, : l10n.l_name_already_exists,
prefixIcon: const Icon(Icons.person_outline),
suffixIcon:
(!isUnique || byteLength(nameText) > nameMaxLength)
? const Icon(Icons.error)
: null,
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
@ -423,18 +426,24 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
// would hint to use saved passwords for this field // would hint to use saved passwords for this field
autofillHints: autofillHints:
isAndroid ? [] : const [AutofillHints.password], isAndroid ? [] : const [AutofillHints.password],
inputFormatters: <TextInputFormatter>[ decoration: AppInputDecoration(
FilteringTextInputFormatter.allow( border: const OutlineInputBorder(),
_secretFormatterPattern) labelText: l10n.s_secret_key,
], errorText: _validateSecret && !secretLengthValid
decoration: InputDecoration( ? l10n.s_invalid_length
: _validateSecret && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.base32.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_isObscure _isObscure
? Icons.visibility ? Icons.visibility
: Icons.visibility_off, : Icons.visibility_off,
color: IconTheme.of(context).color, color: !_validateSecret
), ? IconTheme.of(context).color
: null),
onPressed: () { onPressed: () {
setState(() { setState(() {
_isObscure = !_isObscure; _isObscure = !_isObscure;
@ -443,18 +452,12 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
tooltip: _isObscure tooltip: _isObscure
? l10n.s_show_secret_key ? l10n.s_show_secret_key
: l10n.s_hide_secret_key, : l10n.s_hide_secret_key,
), )),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.key_outlined),
labelText: l10n.s_secret_key,
errorText: _validateSecretLength && !secretLengthValid
? l10n.s_invalid_length
: null),
readOnly: _dataLoaded, readOnly: _dataLoaded,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_validateSecretLength = false; _validateSecret = false;
}); });
}, },
onSubmitted: (_) { onSubmitted: (_) {

View File

@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/focus_utils.dart'; import '../../widgets/focus_utils.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
@ -42,6 +43,9 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
String _newPassword = ''; String _newPassword = '';
String _confirmPassword = ''; String _confirmPassword = '';
bool _currentIsWrong = false; bool _currentIsWrong = false;
bool _isObscureCurrent = true;
bool _isObscureNew = true;
bool _isObscureConfirm = true;
_submit() async { _submit() async {
FocusUtils.unfocus(context); FocusUtils.unfocus(context);
@ -85,15 +89,28 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
Text(l10n.p_enter_current_password_or_reset), Text(l10n.p_enter_current_password_or_reset),
AppTextField( AppTextField(
autofocus: true, autofocus: true,
obscureText: true, obscureText: _isObscureCurrent,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
key: keys.currentPasswordField, key: keys.currentPasswordField,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_current_password, labelText: l10n.s_current_password,
prefixIcon: const Icon(Icons.password_outlined), errorText: _currentIsWrong ? l10n.s_wrong_password : null,
errorText: _currentIsWrong ? l10n.s_wrong_password : null, errorMaxLines: 3,
errorMaxLines: 3), prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureCurrent
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureCurrent = !_isObscureCurrent;
});
},
tooltip: _isObscureCurrent
? l10n.s_show_password
: l10n.s_hide_password),
),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@ -145,12 +162,24 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
AppTextField( AppTextField(
key: keys.newPasswordField, key: keys.newPasswordField,
autofocus: !widget.state.hasKey, autofocus: !widget.state.hasKey,
obscureText: true, obscureText: _isObscureNew,
autofillHints: const [AutofillHints.newPassword], autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_new_password, labelText: l10n.s_new_password,
prefixIcon: const Icon(Icons.password_outlined), prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureNew
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureNew = !_isObscureNew;
});
},
tooltip: _isObscureNew
? l10n.s_show_password
: l10n.s_hide_password),
enabled: !widget.state.hasKey || _currentPassword.isNotEmpty, enabled: !widget.state.hasKey || _currentPassword.isNotEmpty,
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
@ -167,12 +196,24 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
), ),
AppTextField( AppTextField(
key: keys.confirmPasswordField, key: keys.confirmPasswordField,
obscureText: true, obscureText: _isObscureConfirm,
autofillHints: const [AutofillHints.newPassword], autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_confirm_password, labelText: l10n.s_confirm_password,
prefixIcon: const Icon(Icons.password_outlined), prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureConfirm
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureConfirm = !_isObscureConfirm;
});
},
tooltip: _isObscureConfirm
? l10n.s_show_password
: l10n.s_hide_password),
enabled: enabled:
(!widget.state.hasKey || _currentPassword.isNotEmpty) && (!widget.state.hasKey || _currentPassword.isNotEmpty) &&
_newPassword.isNotEmpty, _newPassword.isNotEmpty,

View File

@ -26,6 +26,7 @@ import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart'; import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart'; import '../../widgets/app_text_form_field.dart';
import '../features.dart' as features; import '../features.dart' as features;
import '../keys.dart' as keys; import '../keys.dart' as keys;
@ -161,7 +162,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
// Use the default style, but with a smaller font size: // Use the default style, but with a smaller font size:
style: textTheme.titleMedium style: textTheme.titleMedium
?.copyWith(fontSize: textTheme.titleSmall?.fontSize), ?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
decoration: InputDecoration( decoration: AppInputDecoration(
hintText: l10n.s_search_accounts, hintText: l10n.s_search_accounts,
border: const OutlineInputBorder( border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(32)), borderRadius: BorderRadius.all(Radius.circular(32)),

View File

@ -25,6 +25,7 @@ import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../exception/cancellation_exception.dart'; import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart'; import '../../widgets/app_text_form_field.dart';
import '../../widgets/focus_utils.dart'; import '../../widgets/focus_utils.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
@ -179,7 +180,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
buildCounter: buildByteCounterFor(_issuer), buildCounter: buildByteCounterFor(_issuer),
inputFormatters: [limitBytesLength(issuerRemaining)], inputFormatters: [limitBytesLength(issuerRemaining)],
key: keys.issuerField, key: keys.issuerField,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_issuer_optional, labelText: l10n.s_issuer_optional,
helperText: '', // Prevents dialog resizing when disabled helperText: '', // Prevents dialog resizing when disabled
@ -198,7 +199,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
inputFormatters: [limitBytesLength(nameRemaining)], inputFormatters: [limitBytesLength(nameRemaining)],
buildCounter: buildByteCounterFor(_name), buildCounter: buildByteCounterFor(_name),
key: keys.nameField, key: keys.nameField,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_account_name, labelText: l10n.s_account_name,
helperText: '', // Prevents dialog resizing when disabled helperText: '', // Prevents dialog resizing when disabled

View File

@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
import '../models.dart'; import '../models.dart';
@ -79,7 +80,7 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
autofocus: true, autofocus: true,
obscureText: _isObscure, obscureText: _isObscure,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_password, labelText: l10n.s_password,
errorText: _passwordIsWrong ? l10n.s_wrong_password : null, errorText: _passwordIsWrong ? l10n.s_wrong_password : null,
@ -87,9 +88,10 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
prefixIcon: const Icon(Icons.password_outlined), prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off, _isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color, color: !_passwordIsWrong
), ? IconTheme.of(context).color
: null),
onPressed: () { onPressed: () {
setState(() { setState(() {
_isObscure = !_isObscure; _isObscure = !_isObscure;
@ -105,37 +107,48 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
}), // Update state on change }), // Update state on change
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),
), ),
], const SizedBox(height: 8.0),
), Column(
), crossAxisAlignment: CrossAxisAlignment.stretch,
keystoreFailed children: [
? ListTile( Wrap(
leading: const Icon(Icons.warning_amber_rounded), alignment: WrapAlignment.spaceBetween,
title: Text(l10n.l_keystore_unavailable), crossAxisAlignment: WrapCrossAlignment.center,
dense: true, spacing: 4.0,
minLeadingWidth: 0, runSpacing: 8.0,
) children: [
: CheckboxListTile( keystoreFailed
title: Text(l10n.s_remember_password), ? Wrap(
dense: true, crossAxisAlignment: WrapCrossAlignment.center,
controlAffinity: ListTileControlAffinity.leading, spacing: 4.0,
value: _remember, runSpacing: 8.0,
onChanged: (value) { children: [
setState(() { const Icon(Icons.warning_amber_rounded),
_remember = value ?? false; Text(l10n.l_keystore_unavailable)
}); ],
}, )
: FilterChip(
label: Text(l10n.s_remember_password),
selected: _remember,
onSelected: (value) {
setState(() {
_remember = value;
});
},
),
ElevatedButton.icon(
key: keys.unlockButton,
label: Text(l10n.s_unlock),
icon: const Icon(Icons.lock_open),
onPressed: _passwordController.text.isNotEmpty
? _submit
: null,
),
],
),
],
), ),
Padding( ],
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
key: keys.unlockButton,
label: Text(l10n.s_unlock),
icon: const Icon(Icons.lock_open),
onPressed: _passwordController.text.isNotEmpty ? _submit : null,
),
), ),
), ),
], ],

30
lib/otp/features.dart Normal file
View File

@ -0,0 +1,30 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '../app/features.dart';
final actions = otp.feature('actions');
final actionsSwap = actions.feature('swap');
final slots = otp.feature('slots');
final slotsConfigureChalResp = slots.feature('configureChalResp');
final slotsConfigureHotp = slots.feature('configureHotp');
final slotsConfigureStatic = slots.feature('configureSlots');
final slotsConfigureYubiOtp = slots.feature('configureYubiOtp');
final slotsDelete = slots.feature('delete');

38
lib/otp/keys.dart Normal file
View File

@ -0,0 +1,38 @@
/*
* Copyright (C) 2022-2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
const _prefix = 'otp.keys';
const _keyAction = '$_prefix.actions';
const _slotAction = '$_prefix.slot.actions';
// Key actions
const swapSlots = Key('$_keyAction.swap_slots');
// Slot actions
const configureYubiOtp = Key('$_slotAction.configure_yubiotp');
const configureHotp = Key('$_slotAction.configure_hotp');
const configureStatic = Key('$_slotAction.configure_static');
const configureChalResp = Key('$_slotAction.configure_chal_resp');
const deleteAction = Key('$_slotAction.delete');
const saveButton = Key('$_prefix.save');
const deleteButton = Key('$_prefix.delete');
const secretField = Key('$_prefix.secret');
const publicIdField = Key('$_prefix.public_id');
const privateIdField = Key('$_prefix.private_id');

113
lib/otp/models.dart Normal file
View File

@ -0,0 +1,113 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'models.freezed.dart';
part 'models.g.dart';
enum SlotId {
one('one', 1),
two('two', 2);
final String id;
final int numberId;
const SlotId(this.id, this.numberId);
String getDisplayName(AppLocalizations l10n) {
return switch (this) {
SlotId.one => l10n.s_otp_slot_one,
SlotId.two => l10n.s_otp_slot_two
};
}
factory SlotId.fromJson(String value) =>
SlotId.values.firstWhere((e) => e.id == value);
}
@freezed
class OtpState with _$OtpState {
const OtpState._();
factory OtpState({
required bool slot1Configured,
required bool slot2Configured,
}) = _OtpState;
factory OtpState.fromJson(Map<String, dynamic> json) =>
_$OtpStateFromJson(json);
List<OtpSlot> get slots => [
OtpSlot(slot: SlotId.one, isConfigured: slot1Configured),
OtpSlot(slot: SlotId.two, isConfigured: slot2Configured),
];
}
@freezed
class OtpSlot with _$OtpSlot {
factory OtpSlot({required SlotId slot, required bool isConfigured}) =
_OtpSlot;
}
@freezed
class SlotConfigurationOptions with _$SlotConfigurationOptions {
// ignore: invalid_annotation_target
@JsonSerializable(includeIfNull: false)
factory SlotConfigurationOptions(
{bool? digits8,
bool? requireTouch,
bool? appendCr}) = _SlotConfigurationOptions;
factory SlotConfigurationOptions.fromJson(Map<String, dynamic> json) =>
_$SlotConfigurationOptionsFromJson(json);
}
@Freezed(unionKey: 'type', unionValueCase: FreezedUnionCase.snake)
class SlotConfiguration with _$SlotConfiguration {
const SlotConfiguration._();
// ignore: invalid_annotation_target
@JsonSerializable(explicitToJson: true, includeIfNull: false)
const factory SlotConfiguration.hotp(
{required String key,
SlotConfigurationOptions? options}) = _SlotConfigurationHotp;
@FreezedUnionValue('hmac_sha1')
// ignore: invalid_annotation_target
@JsonSerializable(explicitToJson: true, includeIfNull: false)
const factory SlotConfiguration.chalresp(
{required String key,
SlotConfigurationOptions? options}) = _SlotConfigurationHmacSha1;
@FreezedUnionValue('static_password')
// ignore: invalid_annotation_target
@JsonSerializable(explicitToJson: true, includeIfNull: false)
const factory SlotConfiguration.static(
{required String password,
required String keyboardLayout,
SlotConfigurationOptions? options}) = _SlotConfigurationStaticPassword;
// ignore: invalid_annotation_target
@JsonSerializable(explicitToJson: true, includeIfNull: false)
const factory SlotConfiguration.yubiotp(
{required String publicId,
required String privateId,
required String key,
SlotConfigurationOptions? options}) = _SlotConfigurationYubiOtp;
factory SlotConfiguration.fromJson(Map<String, dynamic> json) =>
_$SlotConfigurationFromJson(json);
}

1491
lib/otp/models.freezed.dart Normal file

File diff suppressed because it is too large Load Diff

161
lib/otp/models.g.dart Normal file
View File

@ -0,0 +1,161 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$OtpStateImpl _$$OtpStateImplFromJson(Map<String, dynamic> json) =>
_$OtpStateImpl(
slot1Configured: json['slot1_configured'] as bool,
slot2Configured: json['slot2_configured'] as bool,
);
Map<String, dynamic> _$$OtpStateImplToJson(_$OtpStateImpl instance) =>
<String, dynamic>{
'slot1_configured': instance.slot1Configured,
'slot2_configured': instance.slot2Configured,
};
_$SlotConfigurationOptionsImpl _$$SlotConfigurationOptionsImplFromJson(
Map<String, dynamic> json) =>
_$SlotConfigurationOptionsImpl(
digits8: json['digits8'] as bool?,
requireTouch: json['require_touch'] as bool?,
appendCr: json['append_cr'] as bool?,
);
Map<String, dynamic> _$$SlotConfigurationOptionsImplToJson(
_$SlotConfigurationOptionsImpl instance) {
final val = <String, dynamic>{};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('digits8', instance.digits8);
writeNotNull('require_touch', instance.requireTouch);
writeNotNull('append_cr', instance.appendCr);
return val;
}
_$SlotConfigurationHotpImpl _$$SlotConfigurationHotpImplFromJson(
Map<String, dynamic> json) =>
_$SlotConfigurationHotpImpl(
key: json['key'] as String,
options: json['options'] == null
? null
: SlotConfigurationOptions.fromJson(
json['options'] as Map<String, dynamic>),
$type: json['type'] as String?,
);
Map<String, dynamic> _$$SlotConfigurationHotpImplToJson(
_$SlotConfigurationHotpImpl instance) {
final val = <String, dynamic>{
'key': instance.key,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('options', instance.options?.toJson());
val['type'] = instance.$type;
return val;
}
_$SlotConfigurationHmacSha1Impl _$$SlotConfigurationHmacSha1ImplFromJson(
Map<String, dynamic> json) =>
_$SlotConfigurationHmacSha1Impl(
key: json['key'] as String,
options: json['options'] == null
? null
: SlotConfigurationOptions.fromJson(
json['options'] as Map<String, dynamic>),
$type: json['type'] as String?,
);
Map<String, dynamic> _$$SlotConfigurationHmacSha1ImplToJson(
_$SlotConfigurationHmacSha1Impl instance) {
final val = <String, dynamic>{
'key': instance.key,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('options', instance.options?.toJson());
val['type'] = instance.$type;
return val;
}
_$SlotConfigurationStaticPasswordImpl
_$$SlotConfigurationStaticPasswordImplFromJson(Map<String, dynamic> json) =>
_$SlotConfigurationStaticPasswordImpl(
password: json['password'] as String,
keyboardLayout: json['keyboard_layout'] as String,
options: json['options'] == null
? null
: SlotConfigurationOptions.fromJson(
json['options'] as Map<String, dynamic>),
$type: json['type'] as String?,
);
Map<String, dynamic> _$$SlotConfigurationStaticPasswordImplToJson(
_$SlotConfigurationStaticPasswordImpl instance) {
final val = <String, dynamic>{
'password': instance.password,
'keyboard_layout': instance.keyboardLayout,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('options', instance.options?.toJson());
val['type'] = instance.$type;
return val;
}
_$SlotConfigurationYubiOtpImpl _$$SlotConfigurationYubiOtpImplFromJson(
Map<String, dynamic> json) =>
_$SlotConfigurationYubiOtpImpl(
publicId: json['public_id'] as String,
privateId: json['private_id'] as String,
key: json['key'] as String,
options: json['options'] == null
? null
: SlotConfigurationOptions.fromJson(
json['options'] as Map<String, dynamic>),
$type: json['type'] as String?,
);
Map<String, dynamic> _$$SlotConfigurationYubiOtpImplToJson(
_$SlotConfigurationYubiOtpImpl instance) {
final val = <String, dynamic>{
'public_id': instance.publicId,
'private_id': instance.privateId,
'key': instance.key,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('options', instance.options?.toJson());
val['type'] = instance.$type;
return val;
}

52
lib/otp/state.dart Normal file
View File

@ -0,0 +1,52 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/models.dart';
import '../core/state.dart';
import 'models.dart';
final yubiOtpOutputProvider =
StateNotifierProvider<YubiOtpOutputNotifier, File?>(
(ref) => YubiOtpOutputNotifier());
class YubiOtpOutputNotifier extends StateNotifier<File?> {
YubiOtpOutputNotifier() : super(null);
void setOutput(File? file) {
state = file;
}
}
final otpStateProvider = AsyncNotifierProvider.autoDispose
.family<OtpStateNotifier, OtpState, DevicePath>(
() => throw UnimplementedError(),
);
abstract class OtpStateNotifier extends ApplicationStateNotifier<OtpState> {
Future<String> generateStaticPassword(int length, String layout);
Future<String> modhexEncodeSerial(int serial);
Future<Map<String, List<String>>> getKeyboardLayouts();
Future<String> formatYubiOtpCsv(
int serial, String publicId, String privateId, String key);
Future<void> swapSlots();
Future<void> configureSlot(SlotId slot,
{required SlotConfiguration configuration});
Future<void> deleteSlot(SlotId slot);
}

175
lib/otp/views/actions.dart Normal file
View File

@ -0,0 +1,175 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../features.dart' as features;
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'configure_chalresp_dialog.dart';
import 'configure_hotp_dialog.dart';
import 'configure_static_dialog.dart';
import 'configure_yubiotp_dialog.dart';
import 'delete_slot_dialog.dart';
class ConfigureChalRespIntent extends Intent {
const ConfigureChalRespIntent();
}
class ConfigureHotpIntent extends Intent {
const ConfigureHotpIntent();
}
class ConfigureStaticIntent extends Intent {
const ConfigureStaticIntent();
}
class ConfigureYubiOtpIntent extends Intent {
const ConfigureYubiOtpIntent();
}
Widget registerOtpActions(
DevicePath devicePath,
OtpSlot otpSlot, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
}) {
final hasFeature = ref.watch(featureProvider);
return Actions(
actions: {
if (hasFeature(features.slotsConfigureChalResp))
ConfigureChalRespIntent:
CallbackAction<ConfigureChalRespIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureChalrespDialog(devicePath, otpSlot));
});
return null;
}),
if (hasFeature(features.slotsConfigureHotp))
ConfigureHotpIntent:
CallbackAction<ConfigureHotpIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => ConfigureHotpDialog(devicePath, otpSlot));
});
return null;
}),
if (hasFeature(features.slotsConfigureStatic))
ConfigureStaticIntent:
CallbackAction<ConfigureStaticIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
final keyboardLayouts = await ref
.read(otpStateProvider(devicePath).notifier)
.getKeyboardLayouts();
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) => ConfigureStaticDialog(
devicePath, otpSlot, keyboardLayouts));
});
return null;
}),
if (hasFeature(features.slotsConfigureYubiOtp))
ConfigureYubiOtpIntent:
CallbackAction<ConfigureYubiOtpIntent>(onInvoke: (intent) async {
final withContext = ref.read(withContextProvider);
await withContext((context) async {
await showBlurDialog(
context: context,
builder: (context) =>
ConfigureYubiOtpDialog(devicePath, otpSlot));
});
return null;
}),
if (hasFeature(features.slotsDelete))
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
final bool? deleted = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) =>
DeleteSlotDialog(devicePath, otpSlot)) ??
false);
return deleted;
}),
...actions,
},
child: Builder(builder: builder),
);
}
List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
return [
ActionItem(
key: keys.configureYubiOtp,
feature: features.slotsConfigureYubiOtp,
icon: const Icon(Icons.shuffle_outlined),
title: l10n.s_yubiotp,
subtitle: l10n.l_yubiotp_desc,
intent: const ConfigureYubiOtpIntent(),
),
ActionItem(
key: keys.configureChalResp,
feature: features.slotsConfigureChalResp,
icon: const Icon(Icons.key_outlined),
title: l10n.s_challenge_response,
subtitle: l10n.l_challenge_response_desc,
intent: const ConfigureChalRespIntent()),
ActionItem(
key: keys.configureStatic,
feature: features.slotsConfigureStatic,
icon: const Icon(Icons.password_outlined),
title: l10n.s_static_password,
subtitle: l10n.l_static_password_desc,
intent: const ConfigureStaticIntent()),
ActionItem(
key: keys.configureHotp,
feature: features.slotsConfigureHotp,
icon: const Icon(Icons.tag_outlined),
title: l10n.s_hotp,
subtitle: l10n.l_hotp_desc,
intent: const ConfigureHotpIntent()),
ActionItem(
key: keys.deleteAction,
feature: features.slotsDelete,
actionStyle: ActionStyle.error,
icon: const Icon(Icons.delete_outline),
title: l10n.s_delete_slot,
subtitle: l10n.l_delete_slot_desc,
intent: isConfigured ? const DeleteIntent() : null,
)
];
}

View File

@ -0,0 +1,187 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_chalresp_dialog');
class ConfigureChalrespDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
const ConfigureChalrespDialog(this.devicePath, this.otpSlot, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ConfigureChalrespDialogState();
}
class _ConfigureChalrespDialogState
extends ConsumerState<ConfigureChalrespDialog> {
final _secretController = TextEditingController();
bool _validateSecret = false;
bool _requireTouch = false;
final int secretMaxLength = 40;
@override
void dispose() {
_secretController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
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),
actions: [
TextButton(
key: keys.saveButton,
onPressed: !_validateSecret
? () async {
if (!secretLengthValid || !secretFormatValid) {
setState(() {
_validateSecret = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
}
final otpNotifier =
ref.read(otpStateProvider(widget.devicePath).notifier);
try {
await otpNotifier.configureSlot(widget.otpSlot.slot,
configuration: SlotConfiguration.chalresp(
key: secret,
options: SlotConfigurationOptions(
requireTouch: _requireTouch)));
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
showMessage(
context,
l10n.l_slot_credential_configured(
l10n.s_challenge_response));
});
} catch (e) {
_log.error('Failed to program credential', e);
await ref.read(withContextProvider)((context) async {
showMessage(
context,
l10n.p_otp_slot_configuration_error(
widget.otpSlot.slot.getDisplayName(l10n)),
duration: const Duration(seconds: 4),
);
});
}
}
: null,
child: Text(l10n.s_save),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
key: keys.secretField,
autofocus: true,
controller: _secretController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: secretMaxLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_secret_key,
errorText: _validateSecret && !secretLengthValid
? l10n.s_invalid_length
: _validateSecret && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: 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;
});
});
},
tooltip: l10n.s_generate_random,
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validateSecret = false;
});
},
),
FilterChip(
label: Text(l10n.s_require_touch),
selected: _requireTouch,
onSelected: (value) {
setState(() {
_requireTouch = value;
});
},
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,200 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../oath/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_hotp_dialog');
class ConfigureHotpDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
const ConfigureHotpDialog(this.devicePath, this.otpSlot, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ConfigureHotpDialogState();
}
class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
final _secretController = TextEditingController();
bool _validateSecret = false;
int _digits = defaultDigits;
final List<int> _digitsValues = [6, 8];
bool _appendEnter = true;
bool _isObscure = true;
@override
void dispose() {
_secretController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
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),
actions: [
TextButton(
key: keys.saveButton,
onPressed: !_validateSecret
? () async {
if (!secretLengthValid || !secretFormatValid) {
setState(() {
_validateSecret = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
}
final otpNotifier =
ref.read(otpStateProvider(widget.devicePath).notifier);
try {
await otpNotifier.configureSlot(widget.otpSlot.slot,
configuration: SlotConfiguration.hotp(
key: secret,
options: SlotConfigurationOptions(
digits8: _digits == 8,
appendCr: _appendEnter)));
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
showMessage(context,
l10n.l_slot_credential_configured(l10n.s_hotp));
});
} catch (e) {
_log.error('Failed to program credential', e);
await ref.read(withContextProvider)((context) async {
showMessage(
context,
l10n.p_otp_slot_configuration_error(
widget.otpSlot.slot.getDisplayName(l10n)),
duration: const Duration(seconds: 4),
);
});
}
}
: null,
child: Text(l10n.s_save),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
key: keys.secretField,
controller: _secretController,
obscureText: _isObscure,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_secret_key,
helperText: '', // Prevents resizing when errorText shown
errorText: _validateSecret && !secretLengthValid
? l10n.s_invalid_length
: _validateSecret && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.base32.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off,
color: !_validateSecret
? IconTheme.of(context).color
: null),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
tooltip: _isObscure
? l10n.s_show_secret_key
: l10n.s_hide_secret_key,
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validateSecret = false;
});
},
),
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,
selected: _digits != defaultDigits,
itemBuilder: (value) => Text(l10n.s_num_digits(value)),
onChanged: (digits) {
setState(() {
_digits = digits;
});
}),
],
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,222 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_static_dialog');
class ConfigureStaticDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
final Map<String, List<String>> keyboardLayouts;
const ConfigureStaticDialog(
this.devicePath, this.otpSlot, this.keyboardLayouts,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ConfigureStaticDialogState();
}
class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
final _passwordController = TextEditingController();
final passwordMaxLength = 38;
bool _validatePassword = false;
bool _appendEnter = true;
String _keyboardLayout = '';
String _defaultKeyboardLayout = '';
@override
void initState() {
super.initState();
final modhexLayout = widget.keyboardLayouts.keys.toList()[0];
_keyboardLayout = modhexLayout;
_defaultKeyboardLayout = modhexLayout;
}
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
RegExp generateFormatterPattern(String layout) {
final allowedCharacters = widget.keyboardLayouts[layout] ?? [];
final pattern =
allowedCharacters.map((char) => RegExp.escape(char)).join('');
return RegExp('^[$pattern]+\$', caseSensitive: false);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final password = _passwordController.text.replaceAll(' ', '');
final passwordLengthValid =
password.isNotEmpty && password.length <= passwordMaxLength;
final passwordFormatValid =
generateFormatterPattern(_keyboardLayout).hasMatch(password);
return ResponsiveDialog(
title: Text(l10n.s_static_password),
actions: [
TextButton(
key: keys.saveButton,
onPressed: !_validatePassword
? () async {
if (!passwordLengthValid || !passwordFormatValid) {
setState(() {
_validatePassword = true;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
}
final otpNotifier =
ref.read(otpStateProvider(widget.devicePath).notifier);
try {
await otpNotifier.configureSlot(widget.otpSlot.slot,
configuration: SlotConfiguration.static(
password: password,
keyboardLayout: _keyboardLayout,
options: SlotConfigurationOptions(
appendCr: _appendEnter)));
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
showMessage(
context,
l10n.l_slot_credential_configured(
l10n.s_static_password));
});
} catch (e) {
_log.error('Failed to program credential', e);
await ref.read(withContextProvider)((context) async {
showMessage(
context,
l10n.p_otp_slot_configuration_error(
widget.otpSlot.slot.getDisplayName(l10n)),
duration: const Duration(seconds: 4),
);
});
}
}
: null,
child: Text(l10n.s_save),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
key: keys.secretField,
autofocus: true,
controller: _passwordController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: passwordMaxLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_password,
errorText: _validatePassword && !passwordLengthValid
? l10n.s_invalid_length
: _validatePassword && !passwordFormatValid
? l10n.l_invalid_keyboard_character
: null,
prefixIcon: const Icon(Icons.key_outlined),
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(() {
_validatePassword = false;
_passwordController.text = password;
});
},
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validatePassword = false;
});
},
),
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: 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;
});
}),
],
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,375 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'dart:math';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
final _log = Logger('otp.view.configure_yubiotp_dialog');
enum OutputActions {
selectFile,
noOutput;
const OutputActions();
String getDisplayName(AppLocalizations l10n) => switch (this) {
OutputActions.selectFile => l10n.l_select_file,
OutputActions.noOutput => l10n.l_no_export_file
};
}
class ConfigureYubiOtpDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
const ConfigureYubiOtpDialog(this.devicePath, this.otpSlot, {super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ConfigureYubiOtpDialogState();
}
class _ConfigureYubiOtpDialogState
extends ConsumerState<ConfigureYubiOtpDialog> {
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;
@override
void dispose() {
_secretController.dispose();
_publicIdController.dispose();
_privateIdController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final info = ref.watch(currentDeviceDataProvider).valueOrNull?.info;
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 lengthsValid =
secretLengthValid && privateIdLengthValid && publicIdLengthValid;
final outputFile = ref.read(yubiOtpOutputProvider);
Future<bool> selectFile() async {
final filePath = await FilePicker.platform.saveFile(
dialogTitle: l10n.l_export_configuration_file,
allowedExtensions: ['csv'],
type: FileType.custom,
lockParentWindow: true);
if (filePath == null) {
return false;
}
ref.read(yubiOtpOutputProvider.notifier).setOutput(File(filePath));
return true;
}
return ResponsiveDialog(
title: Text(l10n.s_yubiotp),
actions: [
TextButton(
key: keys.saveButton,
onPressed: lengthsValid
? () async {
if (!secretFormatValid ||
!publicIdFormatValid ||
!privatedIdFormatValid) {
setState(() {
_validateSecretFormat = !secretFormatValid;
_validatePublicIdFormat = !publicIdFormatValid;
_validatePrivateIdFormat = !privatedIdFormatValid;
});
return;
}
if (!await confirmOverwrite(context, widget.otpSlot)) {
return;
}
final otpNotifier =
ref.read(otpStateProvider(widget.devicePath).notifier);
try {
await otpNotifier.configureSlot(widget.otpSlot.slot,
configuration: SlotConfiguration.yubiotp(
publicId: publicId,
privateId: privateId,
key: secret,
options: SlotConfigurationOptions(
appendCr: _appendEnter)));
if (outputFile != null) {
final csv = await otpNotifier.formatYubiOtpCsv(
info!.serial!, publicId, privateId, secret);
await outputFile.writeAsString(
'$csv${Platform.lineTerminator}',
mode: FileMode.append);
}
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
showMessage(
context,
outputFile != null
? l10n.l_slot_credential_configured_and_exported(
l10n.s_yubiotp,
outputFile.uri.pathSegments.last)
: l10n.l_slot_credential_configured(
l10n.s_yubiotp));
});
} catch (e) {
_log.error('Failed to program credential', e);
await ref.read(withContextProvider)((context) async {
final String errorMessage;
if (e is PathNotFoundException) {
errorMessage = '${e.message} ${e.path.toString()}';
} else {
errorMessage = l10n.p_otp_slot_configuration_error(
widget.otpSlot.slot.getDisplayName(l10n));
}
showMessage(
context,
errorMessage,
duration: const Duration(seconds: 4),
);
});
}
}
: null,
child: Text(l10n.s_save),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppTextField(
key: keys.publicIdField,
autofocus: true,
controller: _publicIdController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: publicIdLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_public_id,
errorText: _validatePublicIdFormat && !publicIdFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.modhex.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.public_outlined),
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,
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validatePublicIdFormat = false;
});
},
),
AppTextField(
key: keys.privateIdField,
controller: _privateIdController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: privateIdLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_private_id,
errorText: _validatePrivateIdFormat && !privatedIdFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
tooltip: l10n.s_generate_random,
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;
});
},
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validatePrivateIdFormat = false;
});
},
),
AppTextField(
key: keys.secretField,
controller: _secretController,
autofillHints: isAndroid ? [] : const [AutofillHints.password],
maxLength: secretLength,
decoration: AppInputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.s_secret_key,
errorText: _validateSecretFormat && !secretFormatValid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton(
tooltip: l10n.s_generate_random,
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;
});
},
)),
textInputAction: TextInputAction.next,
onChanged: (value) {
setState(() {
_validateSecretFormat = false;
});
},
),
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<OutputActions>(
tooltip: outputFile?.path ?? l10n.s_no_export,
selected: outputFile != null,
avatar: outputFile != null
? Icon(Icons.check,
color: Theme.of(context).colorScheme.secondary)
: null,
value: _action,
items: OutputActions.values,
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
labelBuilder: (_) {
String? fileName = outputFile?.uri.pathSegments.last;
return Container(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
fileName != null
? '${l10n.s_export} $fileName'
: _action.getDisplayName(l10n),
overflow: TextOverflow.ellipsis,
),
);
},
onChanged: (value) async {
if (value == OutputActions.noOutput) {
ref.read(yubiOtpOutputProvider.notifier).setOutput(null);
setState(() {
_action = value;
});
} else if (value == OutputActions.selectFile) {
if (await selectFile()) {
setState(() {
_action = value;
});
}
}
},
),
],
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,83 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
class DeleteSlotDialog extends ConsumerWidget {
final DevicePath devicePath;
final OtpSlot otpSlot;
const DeleteSlotDialog(this.devicePath, this.otpSlot, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_delete_slot),
actions: [
TextButton(
key: keys.deleteButton,
onPressed: () async {
try {
await ref
.read(otpStateProvider(devicePath).notifier)
.deleteSlot(otpSlot.slot);
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop(true);
showMessage(context, l10n.l_slot_deleted);
});
} catch (e) {
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop(true);
showMessage(
context,
l10n.p_otp_slot_configuration_error(
otpSlot.slot.getDisplayName(l10n)),
duration: const Duration(seconds: 4),
);
});
}
},
child: Text(l10n.s_delete),
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n
.p_warning_delete_slot_configuration(otpSlot.slot.numberId)),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/views/action_list.dart';
import '../../app/views/fs_dialog.dart';
import '../features.dart' as features;
import '../keys.dart' as keys;
import '../models.dart';
import 'swap_slots_dialog.dart';
Widget otpBuildActions(BuildContext context, DevicePath devicePath,
OtpState otpState, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return FsDialog(
child: Column(
children: [
ActionListSection(l10n.s_manage, children: [
ActionListItem(
key: keys.swapSlots,
feature: features.actionsSwap,
title: l10n.s_swap_slots,
subtitle: l10n.l_swap_slots_desc,
icon: const Icon(Icons.swap_vert_outlined),
onTap: (otpState.slot1Configured || otpState.slot2Configured)
? (context) {
Navigator.of(context).pop();
showBlurDialog(
context: context,
builder: (context) => SwapSlotsDialog(devicePath));
}
: null,
)
])
],
),
);
}

View File

@ -0,0 +1,113 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/views/app_failure_page.dart';
import '../../app/views/app_list_item.dart';
import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../../core/state.dart';
import '../../widgets/list_title.dart';
import '../features.dart' as features;
import '../models.dart';
import '../state.dart';
import 'actions.dart';
import 'key_actions.dart';
import 'slot_dialog.dart';
class OtpScreen extends ConsumerWidget {
final DevicePath devicePath;
const OtpScreen(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final hasFeature = ref.watch(featureProvider);
return ref.watch(otpStateProvider(devicePath)).when(
loading: () => MessagePage(
title: Text(l10n.s_slots),
graphic: const CircularProgressIndicator(),
delayedContent: true,
),
error: (error, _) =>
AppFailurePage(title: Text(l10n.s_slots), cause: error),
data: (otpState) {
return AppPage(
title: Text(l10n.s_slots),
keyActionsBuilder: hasFeature(features.actions)
? (context) =>
otpBuildActions(context, devicePath, otpState, ref)
: null,
child: Column(children: [
ListTitle(l10n.s_slots),
...otpState.slots.map((e) => registerOtpActions(devicePath, e,
ref: ref,
actions: {
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) async {
await showBlurDialog(
context: context,
barrierColor: Colors.transparent,
builder: (context) => SlotDialog(e.slot),
);
return null;
}),
},
builder: (context) => _SlotListItem(e)))
]),
);
});
}
}
class _SlotListItem extends ConsumerWidget {
final OtpSlot otpSlot;
const _SlotListItem(this.otpSlot);
@override
Widget build(BuildContext context, WidgetRef ref) {
final slot = otpSlot.slot;
final l10n = AppLocalizations.of(context)!;
final colorScheme = Theme.of(context).colorScheme;
final isConfigured = otpSlot.isConfigured;
final hasFeature = ref.watch(featureProvider);
return Semantics(
label: slot.getDisplayName(l10n),
child: AppListItem(
leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary,
child: Text(slot.numberId.toString())),
title: slot.getDisplayName(l10n),
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),
),
buildPopupActions: hasFeature(features.slots)
? (context) => buildSlotActions(isConfigured, l10n)
: null,
));
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
class _OverwriteConfirmDialog extends StatelessWidget {
final OtpSlot otpSlot;
const _OverwriteConfirmDialog({
required this.otpSlot,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_overwrite_slot),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(l10n.s_overwrite)),
],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_overwrite_slot_desc(otpSlot.slot.getDisplayName(l10n))),
const SizedBox(height: 12),
],
),
),
);
}
}
Future<bool> confirmOverwrite(BuildContext context, OtpSlot otpSlot) async {
if (otpSlot.isConfigured) {
return await showBlurDialog(
context: context,
builder: (context) => _OverwriteConfirmDialog(
otpSlot: otpSlot,
)) ??
false;
}
return true;
}

View File

@ -0,0 +1,93 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/state.dart';
import '../../app/views/action_list.dart';
import '../../app/views/fs_dialog.dart';
import '../models.dart';
import '../state.dart';
import 'actions.dart';
class SlotDialog extends ConsumerWidget {
final SlotId slot;
const SlotDialog(this.slot, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Solve this in a cleaner way
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
if (node == null) {
// The rest of this method assumes there is a device, and will throw an exception if not.
// This will never be shown, as the dialog will be immediately closed
return const SizedBox();
}
final l10n = AppLocalizations.of(context)!;
final textTheme = Theme.of(context).textTheme;
final otpState = ref.watch(otpStateProvider(node.path)).valueOrNull;
final otpSlot =
otpState!.slots.firstWhereOrNull((element) => element.slot == slot);
if (otpSlot == null) {
return const FsDialog(child: CircularProgressIndicator());
}
return registerOtpActions(node.path, otpSlot,
ref: ref,
builder: (context) => FocusScope(
autofocus: true,
child: FsDialog(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 48, bottom: 16),
child: Column(
children: [
Text(
otpSlot.slot.getDisplayName(l10n),
style: textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
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_setup,
actions: buildSlotActions(otpSlot.isConfigured, l10n),
)
],
),
),
));
}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../widgets/responsive_dialog.dart';
import '../state.dart';
class SwapSlotsDialog extends ConsumerWidget {
final DevicePath devicePath;
const SwapSlotsDialog(this.devicePath, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.s_swap_slots),
actions: [
TextButton(
onPressed: () async {
await ref.read(otpStateProvider(devicePath).notifier).swapSlots();
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
showMessage(context, l10n.l_slots_swapped);
});
},
child: Text(l10n.s_swap))
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_swap_slots_desc),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -15,12 +15,13 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../core/models.dart';
import '../../exception/cancellation_exception.dart'; import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
@ -40,6 +41,7 @@ class AuthenticationDialog extends ConsumerStatefulWidget {
class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> { class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
bool _defaultKeyUsed = false; bool _defaultKeyUsed = false;
bool _keyIsWrong = false; bool _keyIsWrong = false;
bool _keyFormatInvalid = false;
final _keyController = TextEditingController(); final _keyController = TextEditingController();
@override @override
@ -56,6 +58,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
ManagementKeyType.tdes) ManagementKeyType.tdes)
.keyLength * .keyLength *
2; 2;
final keyFormatInvalid = !Format.hex.isValid(_keyController.text);
return ResponsiveDialog( return ResponsiveDialog(
title: Text(l10n.l_unlock_piv_management), title: Text(l10n.l_unlock_piv_management),
actions: [ actions: [
@ -63,6 +66,12 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
key: keys.unlockButton, key: keys.unlockButton,
onPressed: _keyController.text.length == keyLen onPressed: _keyController.text.length == keyLen
? () async { ? () async {
if (keyFormatInvalid) {
setState(() {
_keyFormatInvalid = true;
});
return;
}
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
try { try {
final status = await ref final status = await ref
@ -99,19 +108,20 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
autofocus: true, autofocus: true,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
controller: _keyController, controller: _keyController,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
readOnly: _defaultKeyUsed, readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? keyLen : null, maxLength: !_defaultKeyUsed ? keyLen : null,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_management_key, labelText: l10n.s_management_key,
prefixIcon: const Icon(Icons.key_outlined),
errorText: _keyIsWrong ? l10n.l_wrong_key : null,
errorMaxLines: 3,
helperText: _defaultKeyUsed ? l10n.l_default_key_used : null, helperText: _defaultKeyUsed ? l10n.l_default_key_used : null,
errorText: _keyIsWrong
? l10n.l_wrong_key
: _keyFormatInvalid
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: hasMetadata suffixIcon: hasMetadata
? null ? null
: IconButton( : IconButton(
@ -121,6 +131,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
tooltip: l10n.s_use_default, tooltip: l10n.s_use_default,
onPressed: () { onPressed: () {
setState(() { setState(() {
_keyFormatInvalid = false;
_defaultKeyUsed = !_defaultKeyUsed; _defaultKeyUsed = !_defaultKeyUsed;
if (_defaultKeyUsed) { if (_defaultKeyUsed) {
_keyController.text = defaultManagementKey; _keyController.text = defaultManagementKey;
@ -135,6 +146,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_keyIsWrong = false; _keyIsWrong = false;
_keyFormatInvalid = false;
}); });
}, },
), ),

View File

@ -22,6 +22,7 @@ import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../core/models.dart'; import '../../core/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/choice_filter_chip.dart'; import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
@ -161,12 +162,13 @@ class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
AppTextField( AppTextField(
autofocus: true, autofocus: true,
key: keys.subjectField, key: keys.subjectField,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_subject, labelText: l10n.s_subject,
errorText: _subject.isNotEmpty && _invalidSubject errorText: _subject.isNotEmpty && _invalidSubject
? l10n.l_rfc4514_invalid ? l10n.l_rfc4514_invalid
: null), : null,
),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
enabled: !_generating, enabled: !_generating,
onChanged: (value) { onChanged: (value) {

View File

@ -23,6 +23,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
@ -51,6 +52,7 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
String _password = ''; String _password = '';
bool _passwordIsWrong = false; bool _passwordIsWrong = false;
bool _importing = false; bool _importing = false;
bool _isObscure = true;
@override @override
void initState() { void initState() {
@ -125,15 +127,27 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
Text(l10n.p_password_protected_file), Text(l10n.p_password_protected_file),
AppTextField( AppTextField(
autofocus: true, autofocus: true,
obscureText: true, obscureText: _isObscure,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
key: keys.managementKeyField, key: keys.managementKeyField,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_password, labelText: l10n.s_password,
prefixIcon: const Icon(Icons.password_outlined), errorText: _passwordIsWrong ? l10n.s_wrong_password : null,
errorText: _passwordIsWrong ? l10n.s_wrong_password : null, errorMaxLines: 3,
errorMaxLines: 3), prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
tooltip: _isObscure
? l10n.s_show_password
: l10n.s_hide_password),
),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {

View File

@ -17,13 +17,14 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../core/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/app_text_form_field.dart'; import '../../widgets/app_text_form_field.dart';
import '../../widgets/choice_filter_chip.dart'; import '../../widgets/choice_filter_chip.dart';
@ -49,10 +50,13 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
late bool _usesStoredKey; late bool _usesStoredKey;
late bool _storeKey; late bool _storeKey;
bool _currentIsWrong = false; bool _currentIsWrong = false;
bool _currentInvalidFormat = false;
bool _newInvalidFormat = false;
int _attemptsRemaining = -1; int _attemptsRemaining = -1;
ManagementKeyType _keyType = ManagementKeyType.tdes; ManagementKeyType _keyType = ManagementKeyType.tdes;
final _currentController = TextEditingController(); final _currentController = TextEditingController();
final _keyController = TextEditingController(); final _keyController = TextEditingController();
bool _isObscure = true;
@override @override
void initState() { void initState() {
@ -76,6 +80,16 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
} }
_submit() async { _submit() async {
final currentInvalidFormat = Format.hex.isValid(_currentController.text);
final newInvalidFormat = Format.hex.isValid(_keyController.text);
if (!currentInvalidFormat || !newInvalidFormat) {
setState(() {
_currentInvalidFormat = !currentInvalidFormat;
_newInvalidFormat = !newInvalidFormat;
});
return;
}
final notifier = ref.read(pivStateProvider(widget.path).notifier); final notifier = ref.read(pivStateProvider(widget.path).notifier);
if (_usesStoredKey) { if (_usesStoredKey) {
final status = (await notifier.verifyPin(_currentController.text)).when( final status = (await notifier.verifyPin(_currentController.text)).when(
@ -155,24 +169,37 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
if (protected) if (protected)
AppTextField( AppTextField(
autofocus: true, autofocus: true,
obscureText: true, obscureText: _isObscure,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
key: keys.pinPukField, key: keys.pinPukField,
maxLength: 8, maxLength: 8,
controller: _currentController, controller: _currentController,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_pin, labelText: l10n.s_pin,
prefixIcon: const Icon(Icons.pin_outlined), errorText: _currentIsWrong
errorText: _currentIsWrong ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
? l10n : _currentInvalidFormat
.l_wrong_pin_attempts_remaining(_attemptsRemaining) ? l10n.l_invalid_format_allowed_chars(
: null, Format.hex.allowedCharacters)
errorMaxLines: 3), : null,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
tooltip: _isObscure ? l10n.s_show_pin : l10n.s_hide_pin),
),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_currentIsWrong = false; _currentIsWrong = false;
_currentInvalidFormat = false;
}); });
}, },
), ),
@ -184,13 +211,18 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
controller: _currentController, controller: _currentController,
readOnly: _defaultKeyUsed, readOnly: _defaultKeyUsed,
maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null, maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_current_management_key, labelText: l10n.s_current_management_key,
prefixIcon: const Icon(Icons.key_outlined),
errorText: _currentIsWrong ? l10n.l_wrong_key : null,
errorMaxLines: 3,
helperText: _defaultKeyUsed ? l10n.l_default_key_used : null, helperText: _defaultKeyUsed ? l10n.l_default_key_used : null,
errorText: _currentIsWrong
? l10n.l_wrong_key
: _currentInvalidFormat
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: _hasMetadata suffixIcon: _hasMetadata
? null ? null
: IconButton( : IconButton(
@ -210,10 +242,6 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
}, },
), ),
), ),
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@ -227,15 +255,15 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
autofillHints: const [AutofillHints.newPassword], autofillHints: const [AutofillHints.newPassword],
maxLength: hexLength, maxLength: hexLength,
controller: _keyController, controller: _keyController,
inputFormatters: <TextInputFormatter>[ decoration: AppInputDecoration(
FilteringTextInputFormatter.allow(
RegExp('[a-f0-9]', caseSensitive: false))
],
decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_new_management_key, labelText: l10n.s_new_management_key,
prefixIcon: const Icon(Icons.key_outlined), errorText: _newInvalidFormat
? l10n.l_invalid_format_allowed_chars(
Format.hex.allowedCharacters)
: null,
enabled: currentLenOk, enabled: currentLenOk,
prefixIcon: const Icon(Icons.key_outlined),
suffixIcon: IconButton( suffixIcon: IconButton(
key: keys.managementKeyRefresh, key: keys.managementKeyRefresh,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
@ -251,6 +279,7 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
.padLeft(2, '0')).join(); .padLeft(2, '0')).join();
setState(() { setState(() {
_keyController.text = key; _keyController.text = key;
_newInvalidFormat = false;
}); });
} }
: null, : null,

View File

@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
@ -44,6 +45,9 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
String _confirmPin = ''; String _confirmPin = '';
bool _currentIsWrong = false; bool _currentIsWrong = false;
int _attemptsRemaining = -1; int _attemptsRemaining = -1;
bool _isObscureCurrent = true;
bool _isObscureNew = true;
bool _isObscureConfirm = true;
_submit() async { _submit() async {
final notifier = ref.read(pivStateProvider(widget.path).notifier); final notifier = ref.read(pivStateProvider(widget.path).notifier);
@ -104,24 +108,38 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
: l10n.p_enter_current_puk_or_reset), : l10n.p_enter_current_puk_or_reset),
AppTextField( AppTextField(
autofocus: true, autofocus: true,
obscureText: true, obscureText: _isObscureCurrent,
maxLength: 8, maxLength: 8,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
key: keys.pinPukField, key: keys.pinPukField,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.pin labelText: widget.target == ManageTarget.pin
? l10n.s_current_pin ? l10n.s_current_pin
: l10n.s_current_puk, : l10n.s_current_puk,
prefixIcon: const Icon(Icons.password_outlined), errorText: _currentIsWrong
errorText: _currentIsWrong ? (widget.target == ManageTarget.pin
? (widget.target == ManageTarget.pin ? l10n
? l10n.l_wrong_pin_attempts_remaining( .l_wrong_pin_attempts_remaining(_attemptsRemaining)
_attemptsRemaining) : l10n
: l10n.l_wrong_puk_attempts_remaining( .l_wrong_puk_attempts_remaining(_attemptsRemaining))
_attemptsRemaining)) : null,
: null, errorMaxLines: 3,
errorMaxLines: 3), prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureCurrent
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureCurrent = !_isObscureCurrent;
});
},
tooltip: widget.target == ManageTarget.pin
? (_isObscureCurrent ? l10n.s_show_pin : l10n.s_hide_pin)
: (_isObscureCurrent ? l10n.s_show_puk : l10n.s_hide_puk),
),
),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@ -134,15 +152,27 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)), widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)),
AppTextField( AppTextField(
key: keys.newPinPukField, key: keys.newPinPukField,
obscureText: true, obscureText: _isObscureNew,
maxLength: 8, maxLength: 8,
autofillHints: const [AutofillHints.newPassword], autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.puk labelText: widget.target == ManageTarget.puk
? l10n.s_new_puk ? l10n.s_new_puk
: l10n.s_new_pin, : l10n.s_new_pin,
prefixIcon: const Icon(Icons.password_outlined), prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(
_isObscureNew ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureNew = !_isObscureNew;
});
},
tooltip: widget.target == ManageTarget.pin
? (_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 // Old YubiKeys allowed a 4 digit PIN
enabled: _currentPin.length >= 4, enabled: _currentPin.length >= 4,
), ),
@ -160,15 +190,28 @@ class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
), ),
AppTextField( AppTextField(
key: keys.confirmPinPukField, key: keys.confirmPinPukField,
obscureText: true, obscureText: _isObscureConfirm,
maxLength: 8, maxLength: 8,
autofillHints: const [AutofillHints.newPassword], autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: widget.target == ManageTarget.puk labelText: widget.target == ManageTarget.puk
? l10n.s_confirm_puk ? l10n.s_confirm_puk
: l10n.s_confirm_pin, : l10n.s_confirm_pin,
prefixIcon: const Icon(Icons.password_outlined), prefixIcon: const Icon(Icons.password_outlined),
suffixIcon: IconButton(
icon: Icon(_isObscureConfirm
? Icons.visibility
: Icons.visibility_off),
onPressed: () {
setState(() {
_isObscureConfirm = !_isObscureConfirm;
});
},
tooltip: widget.target == ManageTarget.pin
? (_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: _currentPin.length >= 4 && _newPin.length >= 6,
), ),
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,

View File

@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../exception/cancellation_exception.dart'; import '../../exception/cancellation_exception.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_field.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys; import '../keys.dart' as keys;
@ -93,19 +94,18 @@ class _PinDialogState extends ConsumerState<PinDialog> {
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
key: keys.managementKeyField, key: keys.managementKeyField,
controller: _pinController, controller: _pinController,
decoration: InputDecoration( decoration: AppInputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: l10n.s_pin, labelText: l10n.s_pin,
prefixIcon: const Icon(Icons.pin_outlined),
errorText: _pinIsWrong errorText: _pinIsWrong
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining) ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
: null, : null,
errorMaxLines: 3, errorMaxLines: 3,
prefixIcon: const Icon(Icons.pin_outlined),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_isObscure ? Icons.visibility : Icons.visibility_off, _isObscure ? Icons.visibility : Icons.visibility_off,
color: IconTheme.of(context).color, color: !_pinIsWrong ? IconTheme.of(context).color : null),
),
onPressed: () { onPressed: () {
setState(() { setState(() {
_isObscure = !_isObscure; _isObscure = !_isObscure;

View File

@ -46,18 +46,18 @@ class PivScreen extends ConsumerWidget {
final hasFeature = ref.watch(featureProvider); final hasFeature = ref.watch(featureProvider);
return ref.watch(pivStateProvider(devicePath)).when( return ref.watch(pivStateProvider(devicePath)).when(
loading: () => MessagePage( loading: () => MessagePage(
title: Text(l10n.s_piv), title: Text(l10n.s_certificates),
graphic: const CircularProgressIndicator(), graphic: const CircularProgressIndicator(),
delayedContent: true, delayedContent: true,
), ),
error: (error, _) => AppFailurePage( error: (error, _) => AppFailurePage(
title: Text(l10n.s_piv), title: Text(l10n.s_certificates),
cause: error, cause: error,
), ),
data: (pivState) { data: (pivState) {
final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData; final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData;
return AppPage( return AppPage(
title: Text(l10n.s_piv), title: Text(l10n.s_certificates),
keyActionsBuilder: hasFeature(features.actions) keyActionsBuilder: hasFeature(features.actions)
? (context) => ? (context) =>
pivBuildActions(context, devicePath, pivState, ref) pivBuildActions(context, devicePath, pivState, ref)

View File

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
class AppInputDecoration extends InputDecoration {
final List<Widget>? suffixIcons;
const AppInputDecoration({
// allow multiple suffixIcons
this.suffixIcons,
// forward other TextField parameters
super.icon,
super.iconColor,
super.label,
super.labelText,
super.labelStyle,
super.floatingLabelStyle,
super.helperText,
super.helperStyle,
super.helperMaxLines,
super.hintText,
super.hintStyle,
super.hintTextDirection,
super.hintMaxLines,
super.hintFadeDuration,
super.error,
super.errorText,
super.errorStyle,
super.errorMaxLines,
super.floatingLabelBehavior,
super.floatingLabelAlignment,
super.isCollapsed,
super.isDense,
super.contentPadding,
super.prefixIcon,
super.prefixIconConstraints,
super.prefix,
super.prefixText,
super.prefixStyle,
super.prefixIconColor,
super.suffixIcon,
super.suffix,
super.suffixText,
super.suffixStyle,
super.suffixIconColor,
super.suffixIconConstraints,
super.counter,
super.counterText,
super.counterStyle,
super.filled,
super.fillColor,
super.focusColor,
super.hoverColor,
super.errorBorder,
super.focusedBorder,
super.focusedErrorBorder,
super.disabledBorder,
super.enabledBorder,
super.border,
super.enabled = true,
super.semanticCounterText,
super.alignLabelWithHint,
super.constraints,
}) : assert(!(suffixIcon != null && suffixIcons != null),
'Declaring both suffixIcon and suffixIcons is not supported.');
@override
Widget? get suffixIcon {
final icons = [
if (super.suffixIcon != null) super.suffixIcon!,
if (suffixIcons != null) ...suffixIcons!,
if (errorText != null) const Icon(Icons.error_outlined),
];
return switch (icons.length) {
0 => null,
1 => icons.single,
_ => Builder(
builder: (context) {
// Apply the constraints to *each* icon.
final constraints = suffixIconConstraints ??
Theme.of(context).visualDensity.effectiveConstraints(
const BoxConstraints(
minWidth: kMinInteractiveDimension,
minHeight: kMinInteractiveDimension,
),
);
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
children: [
for (Widget icon in icons)
ConstrainedBox(
constraints: constraints,
child: icon,
)
],
);
},
),
};
}
}

View File

@ -16,6 +16,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_input_decoration.dart';
/// TextField without autocorrect and suggestions /// TextField without autocorrect and suggestions
class AppTextField extends TextField { class AppTextField extends TextField {
const AppTextField({ const AppTextField({
@ -28,7 +30,7 @@ class AppTextField extends TextField {
super.controller, super.controller,
super.focusNode, super.focusNode,
super.undoController, super.undoController,
super.decoration, AppInputDecoration? decoration,
super.textInputAction, super.textInputAction,
super.textCapitalization, super.textCapitalization,
super.style, super.style,
@ -83,5 +85,5 @@ class AppTextField extends TextField {
super.canRequestFocus, super.canRequestFocus,
super.spellCheckConfiguration, super.spellCheckConfiguration,
super.magnifierConfiguration, super.magnifierConfiguration,
}); }) : super(decoration: decoration);
} }

View File

@ -16,6 +16,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_input_decoration.dart';
/// TextFormField without autocorrect and suggestions /// TextFormField without autocorrect and suggestions
class AppTextFormField extends TextFormField { class AppTextFormField extends TextFormField {
AppTextFormField({ AppTextFormField({
@ -28,7 +30,7 @@ class AppTextFormField extends TextFormField {
super.controller, super.controller,
super.initialValue, super.initialValue,
super.focusNode, super.focusNode,
super.decoration, AppInputDecoration? decoration,
super.textCapitalization, super.textCapitalization,
super.textInputAction, super.textInputAction,
super.style, super.style,
@ -87,5 +89,5 @@ class AppTextFormField extends TextFormField {
super.clipBehavior, super.clipBehavior,
super.scribbleEnabled, super.scribbleEnabled,
super.canRequestFocus, super.canRequestFocus,
}); }) : super(decoration: decoration);
} }

View File

@ -21,6 +21,7 @@ import 'package:flutter/material.dart';
class ChoiceFilterChip<T> extends StatefulWidget { class ChoiceFilterChip<T> extends StatefulWidget {
final T value; final T value;
final List<T> items; final List<T> items;
final String? tooltip;
final Widget Function(T value) itemBuilder; final Widget Function(T value) itemBuilder;
final Widget Function(T value)? labelBuilder; final Widget Function(T value)? labelBuilder;
final void Function(T value)? onChanged; final void Function(T value)? onChanged;
@ -32,6 +33,7 @@ class ChoiceFilterChip<T> extends StatefulWidget {
required this.items, required this.items,
required this.itemBuilder, required this.itemBuilder,
required this.onChanged, required this.onChanged,
this.tooltip,
this.avatar, this.avatar,
this.selected = false, this.selected = false,
this.labelBuilder, this.labelBuilder,
@ -57,7 +59,6 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
), ),
Offset.zero & overlay.size, Offset.zero & overlay.size,
); );
return await showMenu( return await showMenu(
context: context, context: context,
position: position, position: position,
@ -79,6 +80,7 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FilterChip( return FilterChip(
tooltip: widget.tooltip,
avatar: widget.avatar, avatar: widget.avatar,
labelPadding: const EdgeInsets.only(left: 4), labelPadding: const EdgeInsets.only(left: 4),
label: Row( label: Row(