diff --git a/helper/helper/device.py b/helper/helper/device.py index a3a336d8..4bcf07b8 100644 --- a/helper/helper/device.py +++ b/helper/helper/device.py @@ -413,7 +413,9 @@ class ConnectionNode(RpcNode): or ( # SmartCardConnection can be used over NFC, or on 5.3 and later. isinstance(self._connection, SmartCardConnection) 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 ) ) ) diff --git a/helper/helper/yubiotp.py b/helper/helper/yubiotp.py index ffd0fd2b..390cb86c 100644 --- a/helper/helper/yubiotp.py +++ b/helper/helper/yubiotp.py @@ -14,7 +14,8 @@ 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 ( YubiOtpSession, SLOT, @@ -25,7 +26,17 @@ from yubikit.yubiotp import ( YubiOtpSlotConfiguration, 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 +import struct + +_FAIL_MSG = ( + "Failed to write to the YubiKey. Make sure the device does not " + "have restricted access" +) class YubiOtpNode(RpcNode): @@ -65,6 +76,29 @@ class YubiOtpNode(RpcNode): def two(self): 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( hmac_sha1=HmacSha1SlotConfiguration, @@ -113,7 +147,10 @@ class SlotNode(RpcNode): @action(condition=lambda self: self._maybe_configured(self.slot)) 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)) def calculate(self, params, event, signal): @@ -121,7 +158,7 @@ class SlotNode(RpcNode): response = self.session.calculate_hmac_sha1(self.slot, challenge, event) return dict(response=response) - def _apply_config(self, config, params): + def _apply_options(self, config, options): for option in ( "serial_api_visible", "serial_usb_visible", @@ -140,39 +177,61 @@ class SlotNode(RpcNode): "short_ticket", "manual_update", ): - if option in params: - getattr(config, option)(params.pop(option)) + if option in options: + getattr(config, option)(options.pop(option)) for option in ("tabs", "delay", "pacing", "strong_password"): - if option in params: - getattr(config, option)(*params.pop(option)) + if option in options: + getattr(config, option)(*options.pop(option)) - if "token_id" in params: - token_id, *args = params.pop("token_id") + if "token_id" in options: + token_id, *args = options.pop("token_id") config.token_id(bytes.fromhex(token_id), *args) 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 def put(self, params, event, signal): - config = None - for key in _CONFIG_TYPES: - if key in params: - if config is not None: - raise ValueError("Only one configuration type can be provided.") - config = _CONFIG_TYPES[key]( - *(bytes.fromhex(arg) for arg in params.pop(key)) - ) - if config is None: - raise ValueError("No supported configuration type provided.") - self._apply_config(config, params) - self.session.put_configuration( - self.slot, - config, - params.pop("acc_code", None), - params.pop("cur_acc_code", None), - ) - return dict() + type = params.pop("type") + options = params.pop("options", {}) + args = params + + config = self._get_config(type, **args) + self._apply_options(config, options) + try: + self.session.put_configuration( + self.slot, + config, + params.pop("acc_code", None), + params.pop("cur_acc_code", None), + ) + return dict() + except CommandError: + raise ValueError(_FAIL_MSG) @action( condition=lambda self: self._state.version >= (2, 2, 0) @@ -180,7 +239,7 @@ class SlotNode(RpcNode): ) def update(self, params, event, signal): config = UpdateConfiguration() - self._apply_config(config, params) + self._apply_options(config, params) self.session.update_configuration( self.slot, config, diff --git a/integration_test/otp_test.dart b/integration_test/otp_test.dart new file mode 100644 index 00000000..80364ee2 --- /dev/null +++ b/integration_test/otp_test.dart @@ -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(); + }); + }); +} diff --git a/integration_test/piv_test.dart b/integration_test/piv_test.dart index 3afbcdd7..0d249ff2 100644 --- a/integration_test/piv_test.dart +++ b/integration_test/piv_test.dart @@ -158,7 +158,7 @@ void main() { const shortmanagementkey = 'aaaabbbbccccaaaabbbbccccaaaabbbbccccaaaabbbbccc'; - appTest('Bad managementkey key', (WidgetTester tester) async { + appTest('Out of bounds managementkey key', (WidgetTester tester) async { await tester.configurePiv(); await tester.shortWait(); await tester.tap(find.byKey(manageManagementKeyAction).hitTestable()); @@ -169,12 +169,22 @@ void main() { await tester.longWait(); await tester.tap(find.byKey(saveButton).hitTestable()); 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 await tester.enterText( find.byKey(newPinPukField).hitTestable(), shortmanagementkey); await tester.longWait(); expect(tester.isTextButtonEnabled(saveButton), false); }); + appTest('Change managementkey key', (WidgetTester tester) async { await tester.configurePiv(); await tester.shortWait(); diff --git a/lib/app/models.dart b/lib/app/models.dart index a69f568d..9398dd57 100755 --- a/lib/app/models.dart +++ b/lib/app/models.dart @@ -54,7 +54,8 @@ enum Application { String getDisplayName(AppLocalizations l10n) => switch (this) { Application.oath => l10n.s_authenticator, 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), }; diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index 4faa9e9c..2d59bc49 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -25,6 +25,7 @@ import '../../core/state.dart'; import '../../exception/cancellation_exception.dart'; import '../../fido/views/fido_screen.dart'; import '../../oath/views/oath_screen.dart'; +import '../../otp/views/otp_screen.dart'; import '../../piv/views/piv_screen.dart'; import '../../widgets/custom_icons.dart'; import '../models.dart'; @@ -150,6 +151,7 @@ class MainPage extends ConsumerWidget { Application.oath => OathScreen(data.node.path), Application.fido => FidoScreen(data), Application.piv => PivScreen(data.node.path), + Application.otp => OtpScreen(data.node.path), _ => MessagePage( header: l10n.s_app_not_supported, message: l10n.l_app_not_supported_desc, diff --git a/lib/app/views/navigation.dart b/lib/app/views/navigation.dart index 2315da16..c3fa0191 100644 --- a/lib/app/views/navigation.dart +++ b/lib/app/views/navigation.dart @@ -89,7 +89,7 @@ extension on Application { IconData get _icon => switch (this) { Application.oath => Icons.supervisor_account_outlined, Application.fido => Icons.security_outlined, - Application.otp => Icons.password_outlined, + Application.otp => Icons.touch_app_outlined, Application.piv => Icons.approval_outlined, Application.management => Icons.construction_outlined, Application.openpgp => Icons.key_outlined, @@ -99,7 +99,7 @@ extension on Application { IconData get _filledIcon => switch (this) { Application.oath => Icons.supervisor_account, Application.fido => Icons.security, - Application.otp => Icons.password, + Application.otp => Icons.touch_app, Application.piv => Icons.approval, Application.management => Icons.construction, Application.openpgp => Icons.key, diff --git a/lib/core/models.dart b/lib/core/models.dart index 4187431f..b24d5dde 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -155,3 +155,18 @@ class Version with _$Version implements Comparable { } final DateFormat dateFormatter = DateFormat('yyyy-MM-dd'); + +enum Format { + base32('a-z2-7'), + hex('abcdef0123456789'), + modhex('cbdefghijklnrtuv'); + + final String allowedCharacters; + + const Format(this.allowedCharacters); + + bool isValid(String input) { + return RegExp('^[$allowedCharacters]+\$', caseSensitive: false) + .hasMatch(input); + } +} diff --git a/lib/desktop/init.dart b/lib/desktop/init.dart index 6a6001d8..b66277f6 100755 --- a/lib/desktop/init.dart +++ b/lib/desktop/init.dart @@ -42,12 +42,14 @@ import '../core/state.dart'; import '../fido/state.dart'; import '../management/state.dart'; import '../oath/state.dart'; +import '../otp/state.dart'; import '../piv/state.dart'; import '../version.dart'; import 'devices.dart'; import 'fido/state.dart'; import 'management/state.dart'; import 'oath/state.dart'; +import 'otp/state.dart'; import 'piv/state.dart'; import 'qr_scanner.dart'; import 'rpc.dart'; @@ -189,6 +191,7 @@ Future initialize(List argv) async { Application.fido, Application.piv, Application.management, + Application.otp ])), prefProvider.overrideWithValue(prefs), rpcProvider.overrideWith((_) => rpcFuture), @@ -226,6 +229,8 @@ Future initialize(List argv) async { // PIV pivStateProvider.overrideWithProvider(desktopPivState.call), pivSlotsProvider.overrideWithProvider(desktopPivSlots.call), + // OTP + otpStateProvider.overrideWithProvider(desktopOtpState.call) ], child: YubicoAuthenticatorApp( page: Consumer( diff --git a/lib/desktop/otp/state.dart b/lib/desktop/otp/state.dart new file mode 100644 index 00000000..82ca8d46 --- /dev/null +++ b/lib/desktop/otp/state.dart @@ -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( + (ref, devicePath) => + RpcNodeSession(ref.watch(rpcProvider).requireValue, devicePath, []), +); + +final desktopOtpState = AsyncNotifierProvider.autoDispose + .family( + _DesktopOtpStateNotifier.new); + +class _DesktopOtpStateNotifier extends OtpStateNotifier { + late RpcNodeSession _session; + List _subpath = []; + + @override + FutureOr 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 swapSlots() async { + await _session.command('swap', target: _subpath); + ref.invalidate(_sessionProvider(_session.devicePath)); + } + + @override + Future 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 modhexEncodeSerial(int serial) async { + final result = await _session + .command('serial_modhex', target: _subpath, params: {'serial': serial}); + return result['encoded']; + } + + @override + Future>> getKeyboardLayouts() async { + final result = await _session.command('keyboard_layouts', target: _subpath); + return Map>.from(result.map((key, value) => + MapEntry(key, (value as List).cast().toList()))); + } + + @override + Future 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 deleteSlot(SlotId slot) async { + await _session.command('delete', target: [..._subpath, slot.id]); + ref.invalidateSelf(); + } + + @override + Future configureSlot(SlotId slot, + {required SlotConfiguration configuration}) async { + await _session.command('put', + target: [..._subpath, slot.id], params: configuration.toJson()); + ref.invalidateSelf(); + } +} diff --git a/lib/fido/views/add_fingerprint_dialog.dart b/lib/fido/views/add_fingerprint_dialog.dart index 2b1a556e..26625fd9 100755 --- a/lib/fido/views/add_fingerprint_dialog.dart +++ b/lib/fido/views/add_fingerprint_dialog.dart @@ -28,6 +28,7 @@ import '../../app/message.dart'; import '../../app/models.dart'; import '../../desktop/models.dart'; import '../../fido/models.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; import '../../widgets/responsive_dialog.dart'; import '../../widgets/utf8_utils.dart'; @@ -206,7 +207,7 @@ class _AddFingerprintDialogState extends ConsumerState inputFormatters: [limitBytesLength(15)], buildCounter: buildByteCounterFor(_label), autofocus: true, - decoration: InputDecoration( + decoration: AppInputDecoration( enabled: _fingerprint != null, border: const OutlineInputBorder(), labelText: l10n.s_name, diff --git a/lib/fido/views/locked_page.dart b/lib/fido/views/locked_page.dart index 7698df61..5ea1488e 100755 --- a/lib/fido/views/locked_page.dart +++ b/lib/fido/views/locked_page.dart @@ -23,6 +23,7 @@ import '../../app/views/app_page.dart'; import '../../app/views/graphics.dart'; import '../../app/views/message_page.dart'; import '../../core/state.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../features.dart' as features; import '../models.dart'; @@ -166,7 +167,7 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> { obscureText: _isObscure, autofillHints: const [AutofillHints.password], controller: _pinController, - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, helperText: '', // Prevents dialog resizing @@ -175,9 +176,8 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> { prefixIcon: const Icon(Icons.pin_outlined), suffixIcon: IconButton( icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off, - color: IconTheme.of(context).color, - ), + _isObscure ? Icons.visibility : Icons.visibility_off, + color: !_pinIsWrong ? IconTheme.of(context).color : null), onPressed: () { setState(() { _isObscure = !_isObscure; diff --git a/lib/fido/views/pin_dialog.dart b/lib/fido/views/pin_dialog.dart index 8bc7b732..172d02a7 100755 --- a/lib/fido/views/pin_dialog.dart +++ b/lib/fido/views/pin_dialog.dart @@ -24,6 +24,7 @@ import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../desktop/models.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; import '../../widgets/responsive_dialog.dart'; import '../models.dart'; @@ -48,6 +49,9 @@ class _FidoPinDialogState extends ConsumerState { String? _newPinError; bool _currentIsWrong = false; bool _newIsWrong = false; + bool _isObscureCurrent = true; + bool _isObscureNew = true; + bool _isObscureConfirm = true; @override Widget build(BuildContext context) { @@ -76,14 +80,26 @@ class _FidoPinDialogState extends ConsumerState { AppTextFormField( initialValue: _currentPin, autofocus: true, - obscureText: true, + obscureText: _isObscureCurrent, autofillHints: const [AutofillHints.password], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_current_pin, errorText: _currentIsWrong ? _currentPinError : null, errorMaxLines: 3, 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) { setState(() { @@ -98,15 +114,25 @@ class _FidoPinDialogState extends ConsumerState { AppTextFormField( initialValue: _newPin, autofocus: !hasPin, - obscureText: true, + obscureText: _isObscureNew, autofillHints: const [AutofillHints.password], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_new_pin, enabled: !hasPin || _currentPin.isNotEmpty, errorText: _newIsWrong ? _newPinError : null, errorMaxLines: 3, 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) { setState(() { @@ -117,12 +143,24 @@ class _FidoPinDialogState extends ConsumerState { ), AppTextFormField( initialValue: _confirmPin, - obscureText: true, + obscureText: _isObscureConfirm, autofillHints: const [AutofillHints.password], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_confirm_pin, 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: (!hasPin || _currentPin.isNotEmpty) && _newPin.isNotEmpty, ), diff --git a/lib/fido/views/rename_fingerprint_dialog.dart b/lib/fido/views/rename_fingerprint_dialog.dart index aead748c..60587b20 100755 --- a/lib/fido/views/rename_fingerprint_dialog.dart +++ b/lib/fido/views/rename_fingerprint_dialog.dart @@ -21,6 +21,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../desktop/models.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; import '../../widgets/responsive_dialog.dart'; import '../../widgets/utf8_utils.dart'; @@ -95,7 +96,7 @@ class _RenameAccountDialogState extends ConsumerState { maxLength: 15, inputFormatters: [limitBytesLength(15)], buildCounter: buildByteCounterFor(_label), - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_label, prefixIcon: const Icon(Icons.fingerprint_outlined), diff --git a/lib/fido/views/reset_dialog.dart b/lib/fido/views/reset_dialog.dart index 2fdd853e..9b27f687 100755 --- a/lib/fido/views/reset_dialog.dart +++ b/lib/fido/views/reset_dialog.dart @@ -43,64 +43,76 @@ class ResetDialog extends ConsumerStatefulWidget { class _ResetDialogState extends ConsumerState { StreamSubscription? _subscription; InteractionEvent? _interaction; + int _currentStep = -1; + final _totalSteps = 3; String _getMessage() { final l10n = AppLocalizations.of(context)!; final nfc = widget.node.transport == Transport.nfc; + if (_currentStep == 3) { + return l10n.l_fido_app_reset; + } return switch (_interaction) { InteractionEvent.remove => nfc ? l10n.l_remove_yk_from_reader : l10n.l_unplug_yk, InteractionEvent.insert => nfc ? l10n.l_replace_yk_on_reader : l10n.l_reinsert_yk, InteractionEvent.touch => l10n.l_touch_button_now, - null => l10n.l_press_reset_to_begin + null => '' }; } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + double progress = _currentStep == -1 ? 0.0 : _currentStep / (_totalSteps); return ResponsiveDialog( title: Text(l10n.s_factory_reset), - onCancel: () { - _subscription?.cancel(); - }, - actions: [ - TextButton( - onPressed: _subscription == null - ? () async { - _subscription = ref - .read(fidoStateProvider(widget.node.path).notifier) - .reset() - .listen((event) { - setState(() { - _interaction = event; - }); - }, onDone: () { - _subscription = null; - Navigator.of(context).pop(); - showMessage(context, l10n.l_fido_app_reset); - }, onError: (e) { - _log.error('Error performing FIDO reset', e); - Navigator.of(context).pop(); - final String errorMessage; - // TODO: Make this cleaner than importing desktop specific RpcError. - if (e is RpcError) { - errorMessage = e.message; - } else { - errorMessage = e.toString(); - } - showMessage( - context, - l10n.l_reset_failed(errorMessage), - duration: const Duration(seconds: 4), - ); - }); - } - : null, - child: Text(l10n.s_reset), - ), - ], + onCancel: _currentStep < 3 + ? () { + _subscription?.cancel(); + } + : null, + actions: _currentStep < 3 + ? [ + TextButton( + onPressed: _subscription == null + ? () async { + _subscription = ref + .read(fidoStateProvider(widget.node.path).notifier) + .reset() + .listen((event) { + setState(() { + _currentStep++; + _interaction = event; + }); + }, onDone: () { + setState(() { + _currentStep++; + }); + _subscription = null; + }, onError: (e) { + _log.error('Error performing FIDO reset', e); + Navigator.of(context).pop(); + final String errorMessage; + // TODO: Make this cleaner than importing desktop specific RpcError. + if (e is RpcError) { + errorMessage = e.message; + } else { + errorMessage = e.toString(); + } + showMessage( + context, + l10n.l_reset_failed(errorMessage), + duration: const Duration(seconds: 4), + ); + }); + } + : null, + child: Text(l10n.s_reset), + ), + ] + : [], child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), child: Column( @@ -113,10 +125,10 @@ class _ResetDialogState extends ConsumerState { Text( l10n.p_warning_disable_accounts, ), - Center( - child: Text(_getMessage(), - style: Theme.of(context).textTheme.titleLarge), - ), + if (_currentStep > -1) ...[ + Text('${l10n.s_status}: ${_getMessage()}'), + LinearProgressIndicator(value: progress) + ], ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b1ad2c1d..ee786cdc 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -29,6 +29,7 @@ "s_close": "Schließen", "s_delete": "Löschen", "s_quit": "Beenden", + "s_status": null, "s_unlock": "Entsperren", "s_calculate": "Berechnen", "s_import": null, @@ -61,8 +62,9 @@ "s_manage": "Verwalten", "s_setup": "Einrichten", "s_settings": "Einstellungen", - "s_piv": null, + "s_certificates": null, "s_webauthn": "WebAuthn", + "s_slots": null, "s_help_and_about": "Hilfe und Über", "s_help_and_feedback": "Hilfe und Feedback", "s_send_feedback": "Senden Sie uns Feedback", @@ -78,6 +80,14 @@ "s_hide_secret_key": null, "s_private_key": null, "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", "q_have_account_info": "Haben Sie Konto-Informationen?", "s_run_diagnostics": "Diagnose ausführen", @@ -189,6 +199,8 @@ "s_change_puk": null, "s_show_pin": null, "s_hide_pin": null, + "s_show_puk": null, + "s_hide_puk": null, "s_current_pin": "Derzeitige PIN", "s_current_puk": null, "s_new_pin": "Neue PIN", @@ -286,6 +298,8 @@ "s_management_key": null, "s_current_management_key": null, "s_new_management_key": null, + "s_show_management_key": null, + "s_hide_management_key": null, "l_change_management_key": null, "p_change_management_key_desc": null, "l_management_key_changed": null, @@ -429,7 +443,6 @@ "@_certificates": {}, "s_certificate": null, - "s_certificates": null, "s_csr": null, "s_subject": null, "l_export_csr_file": null, @@ -504,6 +517,74 @@ "s_slot_9d": 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": {}, "s_enable_nfc": "NFC aktivieren", "s_permission_denied": "Zugriff verweigert", @@ -539,7 +620,6 @@ "l_oath_application_reset": "OATH Anwendung zurücksetzen", "s_reset_fido": "FIDO zurücksetzen", "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": { "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8c0acadd..9d63a036 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,6 +29,7 @@ "s_close": "Close", "s_delete": "Delete", "s_quit": "Quit", + "s_status": "Status", "s_unlock": "Unlock", "s_calculate": "Calculate", "s_import": "Import", @@ -61,8 +62,9 @@ "s_manage": "Manage", "s_setup": "Setup", "s_settings": "Settings", - "s_piv": "PIV", + "s_certificates": "Certificates", "s_webauthn": "WebAuthn", + "s_slots": "Slots", "s_help_and_about": "Help and about", "s_help_and_feedback": "Help and feedback", "s_send_feedback": "Send us feedback", @@ -78,6 +80,14 @@ "s_hide_secret_key": "Hide secret key", "s_private_key": "Private key", "s_invalid_length": "Invalid length", + "s_invalid_format": "Invalid format", + "l_invalid_format_allowed_chars": "Invalid format, allowed characters: {characters}", + "@l_invalid_format_allowed_chars": { + "placeholders": { + "characters": {} + } + }, + "l_invalid_keyboard_character": "Invalid characters for selected keyboard", "s_require_touch": "Require touch", "q_have_account_info": "Have account info?", "s_run_diagnostics": "Run diagnostics", @@ -189,6 +199,8 @@ "s_change_puk": "Change PUK", "s_show_pin": "Show PIN", "s_hide_pin": "Hide PIN", + "s_show_puk": "Show PUK", + "s_hide_puk": "Hide PUK", "s_current_pin": "Current PIN", "s_current_puk": "Current PUK", "s_new_pin": "New PIN", @@ -286,6 +298,8 @@ "s_management_key": "Management key", "s_current_management_key": "Current 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", "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", @@ -429,7 +443,6 @@ "@_certificates": {}, "s_certificate": "Certificate", - "s_certificates": "Certificates", "s_csr": "CSR", "s_subject": "Subject", "l_export_csr_file": "Save CSR to file", @@ -504,6 +517,74 @@ "s_slot_9d": "Key Management", "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": {}, "s_enable_nfc": "Enable NFC", "s_permission_denied": "Permission denied", @@ -539,7 +620,6 @@ "l_oath_application_reset": "OATH application reset", "s_reset_fido": "Reset FIDO", "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": { "placeholders": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 73203e3a..fb0439d5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -29,6 +29,7 @@ "s_close": "Fermer", "s_delete": "Supprimer", "s_quit": "Quitter", + "s_status": null, "s_unlock": "Déverrouiller", "s_calculate": "Calculer", "s_import": "Importer", @@ -61,8 +62,9 @@ "s_manage": "Gérer", "s_setup": "Configuration", "s_settings": "Paramètres", - "s_piv": "PIV", + "s_certificates": "Certificats", "s_webauthn": "WebAuthn", + "s_slots": null, "s_help_and_about": "Aide et à propos", "s_help_and_feedback": "Aide et retours", "s_send_feedback": "Envoyer nous un retour", @@ -78,6 +80,14 @@ "s_hide_secret_key": null, "s_private_key": "Clé privée", "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", "q_have_account_info": "Avez-vous des informations de compte?", "s_run_diagnostics": "Exécuter un diagnostique", @@ -189,6 +199,8 @@ "s_change_puk": "Changez PUK", "s_show_pin": null, "s_hide_pin": null, + "s_show_puk": null, + "s_hide_puk": null, "s_current_pin": "PIN actuel", "s_current_puk": "PUK actuel", "s_new_pin": "Nouveau PIN", @@ -286,6 +298,8 @@ "s_management_key": "Gestion des clés", "s_current_management_key": "Clé actuelle 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", "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", @@ -429,7 +443,6 @@ "@_certificates": {}, "s_certificate": "Certificat", - "s_certificates": "Certificats", "s_csr": "CSR", "s_subject": "Sujet", "l_export_csr_file": "Sauvegarder le CSR vers un fichier", @@ -504,6 +517,74 @@ "s_slot_9d": "Gestion des clés", "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": {}, "s_enable_nfc": "Activer le NFC", "s_permission_denied": "Permission refusée", @@ -539,7 +620,6 @@ "l_oath_application_reset": "L'application OATH à été réinitialisée", "s_reset_fido": "Réinitialiser le FIDO", "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": { "placeholders": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 8875fd1c..53595019 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -29,6 +29,7 @@ "s_close": "閉じる", "s_delete": "消去", "s_quit": "終了", + "s_status": null, "s_unlock": "ロック解除", "s_calculate": "計算", "s_import": "インポート", @@ -61,8 +62,9 @@ "s_manage": "管理", "s_setup": "セットアップ", "s_settings": "設定", - "s_piv": "PIV", + "s_certificates": "証明書", "s_webauthn": "WebAuthn", + "s_slots": null, "s_help_and_about": "ヘルプと概要", "s_help_and_feedback": "ヘルプとフィードバック", "s_send_feedback": "フィードバックの送信", @@ -78,6 +80,14 @@ "s_hide_secret_key": null, "s_private_key": "秘密鍵", "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": "タッチが必要", "q_have_account_info": "アカウント情報をお持ちですか?", "s_run_diagnostics": "診断を実行する", @@ -189,6 +199,8 @@ "s_change_puk": "PUKを変更する", "s_show_pin": null, "s_hide_pin": null, + "s_show_puk": null, + "s_hide_puk": null, "s_current_pin": "現在のPIN", "s_current_puk": "現在のPUK", "s_new_pin": "新しいPIN", @@ -286,6 +298,8 @@ "s_management_key": "Management key", "s_current_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の変更", "p_change_management_key_desc": "Management keyを変更してください。Management keyの代わりにPINを使用することも可能です", "l_management_key_changed": "Management keyは変更されました", @@ -429,7 +443,6 @@ "@_certificates": {}, "s_certificate": "証明書", - "s_certificates": "証明書", "s_csr": "CSR", "s_subject": "サブジェクト", "l_export_csr_file": "CSRをファイルに保存", @@ -504,6 +517,74 @@ "s_slot_9d": "鍵の管理", "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": {}, "s_enable_nfc": "NFCを有効にする", "s_permission_denied": "権限がありません", @@ -539,7 +620,6 @@ "l_oath_application_reset": "OATHアプリケーションのリセット", "s_reset_fido": "FIDOのリセット", "l_fido_app_reset": "FIDOアプリケーションのリセット", - "l_press_reset_to_begin": "リセットを押して開始してください\u2026", "l_reset_failed": "リセット実行中のエラー:{message}", "@l_reset_failed": { "placeholders": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 6503d990..44cd4c22 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -29,6 +29,7 @@ "s_close": "Zamknij", "s_delete": "Usuń", "s_quit": "Wyjdź", + "s_status": null, "s_unlock": "Odblokuj", "s_calculate": "Oblicz", "s_import": "Importuj", @@ -61,8 +62,9 @@ "s_manage": "Zarządzaj", "s_setup": "Konfiguruj", "s_settings": "Ustawienia", - "s_piv": "PIV", + "s_certificates": "Certyfikaty", "s_webauthn": "WebAuthn", + "s_slots": null, "s_help_and_about": "Pomoc i informacje", "s_help_and_feedback": "Pomoc i opinie", "s_send_feedback": "Prześlij opinię", @@ -78,6 +80,14 @@ "s_hide_secret_key": "Ukryj tajny klucz", "s_private_key": "Klucz prywatny", "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", "q_have_account_info": "Masz dane konta?", "s_run_diagnostics": "Uruchom diagnostykę", @@ -189,6 +199,8 @@ "s_change_puk": "Zmień PUK", "s_show_pin": "Pokaż PIN", "s_hide_pin": "Ukryj PIN", + "s_show_puk": null, + "s_hide_puk": null, "s_current_pin": "Aktualny PIN", "s_current_puk": "Aktualny PUK", "s_new_pin": "Nowy PIN", @@ -286,6 +298,8 @@ "s_management_key": "Klucz zarządzania", "s_current_management_key": "Aktualny 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", "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", @@ -429,7 +443,6 @@ "@_certificates": {}, "s_certificate": "Certyfikat", - "s_certificates": "Certyfikaty", "s_csr": "CSR", "s_subject": "Temat", "l_export_csr_file": "Zapisz CSR do pliku", @@ -504,6 +517,74 @@ "s_slot_9d": "Menedżer kluczy", "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": {}, "s_enable_nfc": "Włącz NFC", "s_permission_denied": "Odmowa dostępu", @@ -539,7 +620,6 @@ "l_oath_application_reset": "Reset funkcji OATH", "s_reset_fido": "Zresetuj 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": { "placeholders": { diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index efa39f48..8fd3ec7b 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -19,7 +19,6 @@ import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; @@ -30,11 +29,13 @@ import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../app/views/user_interaction.dart'; +import '../../core/models.dart'; import '../../core/state.dart'; import '../../desktop/models.dart'; import '../../exception/apdu_exception.dart'; import '../../exception/cancellation_exception.dart'; import '../../management/models.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/choice_filter_chip.dart'; import '../../widgets/file_drop_target.dart'; @@ -49,9 +50,6 @@ import 'utils.dart'; final _log = Logger('oath.view.add_account_page'); -final _secretFormatterPattern = - RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false); - class OathAddAccountPage extends ConsumerStatefulWidget { final DevicePath? devicePath; final OathState? state; @@ -83,7 +81,7 @@ class _OathAddAccountPageState extends ConsumerState { HashAlgorithm _hashAlgorithm = defaultHashAlgorithm; int _digits = defaultDigits; int _counter = defaultCounter; - bool _validateSecretLength = false; + bool _validateSecret = false; bool _dataLoaded = false; bool _isObscure = true; List _periodValues = [20, 30, 45, 60]; @@ -235,6 +233,7 @@ class _OathAddAccountPageState extends ConsumerState { final secret = _secretController.text.replaceAll(' ', ''); final secretLengthValid = secret.length * 5 % 8 < 5; + final secretFormatValid = Format.base32.isValid(secret); // is this credentials name/issuer pair different from all other? final isUnique = _credentials @@ -271,7 +270,7 @@ class _OathAddAccountPageState extends ConsumerState { } void submit() async { - if (secretLengthValid) { + if (secretLengthValid && secretFormatValid) { final cred = CredentialData( issuer: issuerText.isEmpty ? null : issuerText, name: nameText, @@ -304,7 +303,7 @@ class _OathAddAccountPageState extends ConsumerState { } } else { setState(() { - _validateSecretLength = true; + _validateSecret = true; }); } } @@ -365,17 +364,17 @@ class _OathAddAccountPageState extends ConsumerState { limitBytesLength(issuerRemaining), ], buildCounter: buildByteCounterFor(issuerText), - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_issuer_optional, - helperText: '', - // Prevents dialog resizing when disabled - prefixIcon: const Icon(Icons.business_outlined), + helperText: + '', // Prevents dialog resizing when disabled errorText: (byteLength(issuerText) > issuerMaxLength) ? '' // needs empty string to render as error : issuerNoColon ? null : l10n.l_invalid_character_issuer, + prefixIcon: const Icon(Icons.business_outlined), ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -393,9 +392,8 @@ class _OathAddAccountPageState extends ConsumerState { maxLength: nameMaxLength, buildCounter: buildByteCounterFor(nameText), inputFormatters: [limitBytesLength(nameRemaining)], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), - prefixIcon: const Icon(Icons.person_outline), labelText: l10n.s_account_name, helperText: '', // Prevents dialog resizing when disabled @@ -404,6 +402,11 @@ class _OathAddAccountPageState extends ConsumerState { : isUnique ? null : l10n.l_name_already_exists, + prefixIcon: const Icon(Icons.person_outline), + suffixIcon: + (!isUnique || byteLength(nameText) > nameMaxLength) + ? const Icon(Icons.error) + : null, ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -423,18 +426,24 @@ class _OathAddAccountPageState extends ConsumerState { // would hint to use saved passwords for this field autofillHints: isAndroid ? [] : const [AutofillHints.password], - inputFormatters: [ - FilteringTextInputFormatter.allow( - _secretFormatterPattern) - ], - decoration: InputDecoration( + 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.base32.allowedCharacters) + : null, + prefixIcon: const Icon(Icons.key_outlined), suffixIcon: IconButton( icon: Icon( - _isObscure - ? Icons.visibility - : Icons.visibility_off, - color: IconTheme.of(context).color, - ), + _isObscure + ? Icons.visibility + : Icons.visibility_off, + color: !_validateSecret + ? IconTheme.of(context).color + : null), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -443,18 +452,12 @@ class _OathAddAccountPageState extends ConsumerState { tooltip: _isObscure ? l10n.s_show_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, textInputAction: TextInputAction.done, onChanged: (value) { setState(() { - _validateSecretLength = false; + _validateSecret = false; }); }, onSubmitted: (_) { diff --git a/lib/oath/views/manage_password_dialog.dart b/lib/oath/views/manage_password_dialog.dart index 5bd55c88..0a4a579e 100755 --- a/lib/oath/views/manage_password_dialog.dart +++ b/lib/oath/views/manage_password_dialog.dart @@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/focus_utils.dart'; import '../../widgets/responsive_dialog.dart'; @@ -42,6 +43,9 @@ class _ManagePasswordDialogState extends ConsumerState { String _newPassword = ''; String _confirmPassword = ''; bool _currentIsWrong = false; + bool _isObscureCurrent = true; + bool _isObscureNew = true; + bool _isObscureConfirm = true; _submit() async { FocusUtils.unfocus(context); @@ -85,15 +89,28 @@ class _ManagePasswordDialogState extends ConsumerState { Text(l10n.p_enter_current_password_or_reset), AppTextField( autofocus: true, - obscureText: true, + obscureText: _isObscureCurrent, autofillHints: const [AutofillHints.password], key: keys.currentPasswordField, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.s_current_password, - prefixIcon: const Icon(Icons.password_outlined), - errorText: _currentIsWrong ? l10n.s_wrong_password : null, - errorMaxLines: 3), + decoration: AppInputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_current_password, + errorText: _currentIsWrong ? l10n.s_wrong_password : null, + 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, onChanged: (value) { setState(() { @@ -145,12 +162,24 @@ class _ManagePasswordDialogState extends ConsumerState { AppTextField( key: keys.newPasswordField, autofocus: !widget.state.hasKey, - obscureText: true, + obscureText: _isObscureNew, autofillHints: const [AutofillHints.newPassword], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_new_password, 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, ), textInputAction: TextInputAction.next, @@ -167,12 +196,24 @@ class _ManagePasswordDialogState extends ConsumerState { ), AppTextField( key: keys.confirmPasswordField, - obscureText: true, + obscureText: _isObscureConfirm, autofillHints: const [AutofillHints.newPassword], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_confirm_password, 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: (!widget.state.hasKey || _currentPassword.isNotEmpty) && _newPassword.isNotEmpty, diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 26efcccb..acedf30d 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -26,6 +26,7 @@ import '../../app/views/app_page.dart'; import '../../app/views/graphics.dart'; import '../../app/views/message_page.dart'; import '../../core/state.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; import '../features.dart' as features; import '../keys.dart' as keys; @@ -161,7 +162,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { // Use the default style, but with a smaller font size: style: textTheme.titleMedium ?.copyWith(fontSize: textTheme.titleSmall?.fontSize), - decoration: InputDecoration( + decoration: AppInputDecoration( hintText: l10n.s_search_accounts, border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(32)), diff --git a/lib/oath/views/rename_account_dialog.dart b/lib/oath/views/rename_account_dialog.dart index 992a6ab9..589b92f3 100755 --- a/lib/oath/views/rename_account_dialog.dart +++ b/lib/oath/views/rename_account_dialog.dart @@ -25,6 +25,7 @@ import '../../app/models.dart'; import '../../app/state.dart'; import '../../desktop/models.dart'; import '../../exception/cancellation_exception.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; import '../../widgets/focus_utils.dart'; import '../../widgets/responsive_dialog.dart'; @@ -179,7 +180,7 @@ class _RenameAccountDialogState extends ConsumerState { buildCounter: buildByteCounterFor(_issuer), inputFormatters: [limitBytesLength(issuerRemaining)], key: keys.issuerField, - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_issuer_optional, helperText: '', // Prevents dialog resizing when disabled @@ -198,7 +199,7 @@ class _RenameAccountDialogState extends ConsumerState { inputFormatters: [limitBytesLength(nameRemaining)], buildCounter: buildByteCounterFor(_name), key: keys.nameField, - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_account_name, helperText: '', // Prevents dialog resizing when disabled diff --git a/lib/oath/views/unlock_form.dart b/lib/oath/views/unlock_form.dart index 7ef200a0..b395c533 100755 --- a/lib/oath/views/unlock_form.dart +++ b/lib/oath/views/unlock_form.dart @@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../keys.dart' as keys; import '../models.dart'; @@ -79,7 +80,7 @@ class _UnlockFormState extends ConsumerState { autofocus: true, obscureText: _isObscure, autofillHints: const [AutofillHints.password], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_password, errorText: _passwordIsWrong ? l10n.s_wrong_password : null, @@ -87,9 +88,10 @@ class _UnlockFormState extends ConsumerState { prefixIcon: const Icon(Icons.password_outlined), suffixIcon: IconButton( icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off, - color: IconTheme.of(context).color, - ), + _isObscure ? Icons.visibility : Icons.visibility_off, + color: !_passwordIsWrong + ? IconTheme.of(context).color + : null), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -105,37 +107,48 @@ class _UnlockFormState extends ConsumerState { }), // Update state on change onSubmitted: (_) => _submit(), ), - ], - ), - ), - keystoreFailed - ? ListTile( - leading: const Icon(Icons.warning_amber_rounded), - title: Text(l10n.l_keystore_unavailable), - dense: true, - minLeadingWidth: 0, - ) - : CheckboxListTile( - title: Text(l10n.s_remember_password), - dense: true, - controlAffinity: ListTileControlAffinity.leading, - value: _remember, - onChanged: (value) { - setState(() { - _remember = value ?? false; - }); - }, + const SizedBox(height: 8.0), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4.0, + runSpacing: 8.0, + children: [ + keystoreFailed + ? Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4.0, + runSpacing: 8.0, + children: [ + const Icon(Icons.warning_amber_rounded), + 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, - ), + ], ), ), ], diff --git a/lib/otp/features.dart b/lib/otp/features.dart new file mode 100644 index 00000000..353533d1 --- /dev/null +++ b/lib/otp/features.dart @@ -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'); diff --git a/lib/otp/keys.dart b/lib/otp/keys.dart new file mode 100644 index 00000000..e8b05647 --- /dev/null +++ b/lib/otp/keys.dart @@ -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'); diff --git a/lib/otp/models.dart b/lib/otp/models.dart new file mode 100644 index 00000000..63491d1f --- /dev/null +++ b/lib/otp/models.dart @@ -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 json) => + _$OtpStateFromJson(json); + + List 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 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 json) => + _$SlotConfigurationFromJson(json); +} diff --git a/lib/otp/models.freezed.dart b/lib/otp/models.freezed.dart new file mode 100644 index 00000000..b5f18802 --- /dev/null +++ b/lib/otp/models.freezed.dart @@ -0,0 +1,1491 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +OtpState _$OtpStateFromJson(Map json) { + return _OtpState.fromJson(json); +} + +/// @nodoc +mixin _$OtpState { + bool get slot1Configured => throw _privateConstructorUsedError; + bool get slot2Configured => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $OtpStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $OtpStateCopyWith<$Res> { + factory $OtpStateCopyWith(OtpState value, $Res Function(OtpState) then) = + _$OtpStateCopyWithImpl<$Res, OtpState>; + @useResult + $Res call({bool slot1Configured, bool slot2Configured}); +} + +/// @nodoc +class _$OtpStateCopyWithImpl<$Res, $Val extends OtpState> + implements $OtpStateCopyWith<$Res> { + _$OtpStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? slot1Configured = null, + Object? slot2Configured = null, + }) { + return _then(_value.copyWith( + slot1Configured: null == slot1Configured + ? _value.slot1Configured + : slot1Configured // ignore: cast_nullable_to_non_nullable + as bool, + slot2Configured: null == slot2Configured + ? _value.slot2Configured + : slot2Configured // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$OtpStateImplCopyWith<$Res> + implements $OtpStateCopyWith<$Res> { + factory _$$OtpStateImplCopyWith( + _$OtpStateImpl value, $Res Function(_$OtpStateImpl) then) = + __$$OtpStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({bool slot1Configured, bool slot2Configured}); +} + +/// @nodoc +class __$$OtpStateImplCopyWithImpl<$Res> + extends _$OtpStateCopyWithImpl<$Res, _$OtpStateImpl> + implements _$$OtpStateImplCopyWith<$Res> { + __$$OtpStateImplCopyWithImpl( + _$OtpStateImpl _value, $Res Function(_$OtpStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? slot1Configured = null, + Object? slot2Configured = null, + }) { + return _then(_$OtpStateImpl( + slot1Configured: null == slot1Configured + ? _value.slot1Configured + : slot1Configured // ignore: cast_nullable_to_non_nullable + as bool, + slot2Configured: null == slot2Configured + ? _value.slot2Configured + : slot2Configured // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$OtpStateImpl extends _OtpState { + _$OtpStateImpl({required this.slot1Configured, required this.slot2Configured}) + : super._(); + + factory _$OtpStateImpl.fromJson(Map json) => + _$$OtpStateImplFromJson(json); + + @override + final bool slot1Configured; + @override + final bool slot2Configured; + + @override + String toString() { + return 'OtpState(slot1Configured: $slot1Configured, slot2Configured: $slot2Configured)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$OtpStateImpl && + (identical(other.slot1Configured, slot1Configured) || + other.slot1Configured == slot1Configured) && + (identical(other.slot2Configured, slot2Configured) || + other.slot2Configured == slot2Configured)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => + Object.hash(runtimeType, slot1Configured, slot2Configured); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$OtpStateImplCopyWith<_$OtpStateImpl> get copyWith => + __$$OtpStateImplCopyWithImpl<_$OtpStateImpl>(this, _$identity); + + @override + Map toJson() { + return _$$OtpStateImplToJson( + this, + ); + } +} + +abstract class _OtpState extends OtpState { + factory _OtpState( + {required final bool slot1Configured, + required final bool slot2Configured}) = _$OtpStateImpl; + _OtpState._() : super._(); + + factory _OtpState.fromJson(Map json) = + _$OtpStateImpl.fromJson; + + @override + bool get slot1Configured; + @override + bool get slot2Configured; + @override + @JsonKey(ignore: true) + _$$OtpStateImplCopyWith<_$OtpStateImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$OtpSlot { + SlotId get slot => throw _privateConstructorUsedError; + bool get isConfigured => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $OtpSlotCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $OtpSlotCopyWith<$Res> { + factory $OtpSlotCopyWith(OtpSlot value, $Res Function(OtpSlot) then) = + _$OtpSlotCopyWithImpl<$Res, OtpSlot>; + @useResult + $Res call({SlotId slot, bool isConfigured}); +} + +/// @nodoc +class _$OtpSlotCopyWithImpl<$Res, $Val extends OtpSlot> + implements $OtpSlotCopyWith<$Res> { + _$OtpSlotCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? slot = null, + Object? isConfigured = null, + }) { + return _then(_value.copyWith( + slot: null == slot + ? _value.slot + : slot // ignore: cast_nullable_to_non_nullable + as SlotId, + isConfigured: null == isConfigured + ? _value.isConfigured + : isConfigured // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$OtpSlotImplCopyWith<$Res> implements $OtpSlotCopyWith<$Res> { + factory _$$OtpSlotImplCopyWith( + _$OtpSlotImpl value, $Res Function(_$OtpSlotImpl) then) = + __$$OtpSlotImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({SlotId slot, bool isConfigured}); +} + +/// @nodoc +class __$$OtpSlotImplCopyWithImpl<$Res> + extends _$OtpSlotCopyWithImpl<$Res, _$OtpSlotImpl> + implements _$$OtpSlotImplCopyWith<$Res> { + __$$OtpSlotImplCopyWithImpl( + _$OtpSlotImpl _value, $Res Function(_$OtpSlotImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? slot = null, + Object? isConfigured = null, + }) { + return _then(_$OtpSlotImpl( + slot: null == slot + ? _value.slot + : slot // ignore: cast_nullable_to_non_nullable + as SlotId, + isConfigured: null == isConfigured + ? _value.isConfigured + : isConfigured // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$OtpSlotImpl implements _OtpSlot { + _$OtpSlotImpl({required this.slot, required this.isConfigured}); + + @override + final SlotId slot; + @override + final bool isConfigured; + + @override + String toString() { + return 'OtpSlot(slot: $slot, isConfigured: $isConfigured)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$OtpSlotImpl && + (identical(other.slot, slot) || other.slot == slot) && + (identical(other.isConfigured, isConfigured) || + other.isConfigured == isConfigured)); + } + + @override + int get hashCode => Object.hash(runtimeType, slot, isConfigured); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$OtpSlotImplCopyWith<_$OtpSlotImpl> get copyWith => + __$$OtpSlotImplCopyWithImpl<_$OtpSlotImpl>(this, _$identity); +} + +abstract class _OtpSlot implements OtpSlot { + factory _OtpSlot( + {required final SlotId slot, + required final bool isConfigured}) = _$OtpSlotImpl; + + @override + SlotId get slot; + @override + bool get isConfigured; + @override + @JsonKey(ignore: true) + _$$OtpSlotImplCopyWith<_$OtpSlotImpl> get copyWith => + throw _privateConstructorUsedError; +} + +SlotConfigurationOptions _$SlotConfigurationOptionsFromJson( + Map json) { + return _SlotConfigurationOptions.fromJson(json); +} + +/// @nodoc +mixin _$SlotConfigurationOptions { + bool? get digits8 => throw _privateConstructorUsedError; + bool? get requireTouch => throw _privateConstructorUsedError; + bool? get appendCr => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SlotConfigurationOptionsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SlotConfigurationOptionsCopyWith<$Res> { + factory $SlotConfigurationOptionsCopyWith(SlotConfigurationOptions value, + $Res Function(SlotConfigurationOptions) then) = + _$SlotConfigurationOptionsCopyWithImpl<$Res, SlotConfigurationOptions>; + @useResult + $Res call({bool? digits8, bool? requireTouch, bool? appendCr}); +} + +/// @nodoc +class _$SlotConfigurationOptionsCopyWithImpl<$Res, + $Val extends SlotConfigurationOptions> + implements $SlotConfigurationOptionsCopyWith<$Res> { + _$SlotConfigurationOptionsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? digits8 = freezed, + Object? requireTouch = freezed, + Object? appendCr = freezed, + }) { + return _then(_value.copyWith( + digits8: freezed == digits8 + ? _value.digits8 + : digits8 // ignore: cast_nullable_to_non_nullable + as bool?, + requireTouch: freezed == requireTouch + ? _value.requireTouch + : requireTouch // ignore: cast_nullable_to_non_nullable + as bool?, + appendCr: freezed == appendCr + ? _value.appendCr + : appendCr // ignore: cast_nullable_to_non_nullable + as bool?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SlotConfigurationOptionsImplCopyWith<$Res> + implements $SlotConfigurationOptionsCopyWith<$Res> { + factory _$$SlotConfigurationOptionsImplCopyWith( + _$SlotConfigurationOptionsImpl value, + $Res Function(_$SlotConfigurationOptionsImpl) then) = + __$$SlotConfigurationOptionsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({bool? digits8, bool? requireTouch, bool? appendCr}); +} + +/// @nodoc +class __$$SlotConfigurationOptionsImplCopyWithImpl<$Res> + extends _$SlotConfigurationOptionsCopyWithImpl<$Res, + _$SlotConfigurationOptionsImpl> + implements _$$SlotConfigurationOptionsImplCopyWith<$Res> { + __$$SlotConfigurationOptionsImplCopyWithImpl( + _$SlotConfigurationOptionsImpl _value, + $Res Function(_$SlotConfigurationOptionsImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? digits8 = freezed, + Object? requireTouch = freezed, + Object? appendCr = freezed, + }) { + return _then(_$SlotConfigurationOptionsImpl( + digits8: freezed == digits8 + ? _value.digits8 + : digits8 // ignore: cast_nullable_to_non_nullable + as bool?, + requireTouch: freezed == requireTouch + ? _value.requireTouch + : requireTouch // ignore: cast_nullable_to_non_nullable + as bool?, + appendCr: freezed == appendCr + ? _value.appendCr + : appendCr // ignore: cast_nullable_to_non_nullable + as bool?, + )); + } +} + +/// @nodoc + +@JsonSerializable(includeIfNull: false) +class _$SlotConfigurationOptionsImpl implements _SlotConfigurationOptions { + _$SlotConfigurationOptionsImpl( + {this.digits8, this.requireTouch, this.appendCr}); + + factory _$SlotConfigurationOptionsImpl.fromJson(Map json) => + _$$SlotConfigurationOptionsImplFromJson(json); + + @override + final bool? digits8; + @override + final bool? requireTouch; + @override + final bool? appendCr; + + @override + String toString() { + return 'SlotConfigurationOptions(digits8: $digits8, requireTouch: $requireTouch, appendCr: $appendCr)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SlotConfigurationOptionsImpl && + (identical(other.digits8, digits8) || other.digits8 == digits8) && + (identical(other.requireTouch, requireTouch) || + other.requireTouch == requireTouch) && + (identical(other.appendCr, appendCr) || + other.appendCr == appendCr)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, digits8, requireTouch, appendCr); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SlotConfigurationOptionsImplCopyWith<_$SlotConfigurationOptionsImpl> + get copyWith => __$$SlotConfigurationOptionsImplCopyWithImpl< + _$SlotConfigurationOptionsImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SlotConfigurationOptionsImplToJson( + this, + ); + } +} + +abstract class _SlotConfigurationOptions implements SlotConfigurationOptions { + factory _SlotConfigurationOptions( + {final bool? digits8, + final bool? requireTouch, + final bool? appendCr}) = _$SlotConfigurationOptionsImpl; + + factory _SlotConfigurationOptions.fromJson(Map json) = + _$SlotConfigurationOptionsImpl.fromJson; + + @override + bool? get digits8; + @override + bool? get requireTouch; + @override + bool? get appendCr; + @override + @JsonKey(ignore: true) + _$$SlotConfigurationOptionsImplCopyWith<_$SlotConfigurationOptionsImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SlotConfiguration _$SlotConfigurationFromJson(Map json) { + switch (json['type']) { + case 'hotp': + return _SlotConfigurationHotp.fromJson(json); + case 'hmac_sha1': + return _SlotConfigurationHmacSha1.fromJson(json); + case 'static_password': + return _SlotConfigurationStaticPassword.fromJson(json); + case 'yubiotp': + return _SlotConfigurationYubiOtp.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'type', 'SlotConfiguration', + 'Invalid union type "${json['type']}"!'); + } +} + +/// @nodoc +mixin _$SlotConfiguration { + SlotConfigurationOptions? get options => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(String key, SlotConfigurationOptions? options) + hotp, + required TResult Function(String key, SlotConfigurationOptions? options) + chalresp, + required TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options) + static, + required TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options) + yubiotp, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String key, SlotConfigurationOptions? options)? hotp, + TResult? Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult? Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult? Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String key, SlotConfigurationOptions? options)? hotp, + TResult Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_SlotConfigurationHotp value) hotp, + required TResult Function(_SlotConfigurationHmacSha1 value) chalresp, + required TResult Function(_SlotConfigurationStaticPassword value) static, + required TResult Function(_SlotConfigurationYubiOtp value) yubiotp, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_SlotConfigurationHotp value)? hotp, + TResult? Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult? Function(_SlotConfigurationStaticPassword value)? static, + TResult? Function(_SlotConfigurationYubiOtp value)? yubiotp, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_SlotConfigurationHotp value)? hotp, + TResult Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult Function(_SlotConfigurationStaticPassword value)? static, + TResult Function(_SlotConfigurationYubiOtp value)? yubiotp, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SlotConfigurationCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SlotConfigurationCopyWith<$Res> { + factory $SlotConfigurationCopyWith( + SlotConfiguration value, $Res Function(SlotConfiguration) then) = + _$SlotConfigurationCopyWithImpl<$Res, SlotConfiguration>; + @useResult + $Res call({SlotConfigurationOptions? options}); + + $SlotConfigurationOptionsCopyWith<$Res>? get options; +} + +/// @nodoc +class _$SlotConfigurationCopyWithImpl<$Res, $Val extends SlotConfiguration> + implements $SlotConfigurationCopyWith<$Res> { + _$SlotConfigurationCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? options = freezed, + }) { + return _then(_value.copyWith( + options: freezed == options + ? _value.options + : options // ignore: cast_nullable_to_non_nullable + as SlotConfigurationOptions?, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $SlotConfigurationOptionsCopyWith<$Res>? get options { + if (_value.options == null) { + return null; + } + + return $SlotConfigurationOptionsCopyWith<$Res>(_value.options!, (value) { + return _then(_value.copyWith(options: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$SlotConfigurationHotpImplCopyWith<$Res> + implements $SlotConfigurationCopyWith<$Res> { + factory _$$SlotConfigurationHotpImplCopyWith( + _$SlotConfigurationHotpImpl value, + $Res Function(_$SlotConfigurationHotpImpl) then) = + __$$SlotConfigurationHotpImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String key, SlotConfigurationOptions? options}); + + @override + $SlotConfigurationOptionsCopyWith<$Res>? get options; +} + +/// @nodoc +class __$$SlotConfigurationHotpImplCopyWithImpl<$Res> + extends _$SlotConfigurationCopyWithImpl<$Res, _$SlotConfigurationHotpImpl> + implements _$$SlotConfigurationHotpImplCopyWith<$Res> { + __$$SlotConfigurationHotpImplCopyWithImpl(_$SlotConfigurationHotpImpl _value, + $Res Function(_$SlotConfigurationHotpImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? key = null, + Object? options = freezed, + }) { + return _then(_$SlotConfigurationHotpImpl( + key: null == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as String, + options: freezed == options + ? _value.options + : options // ignore: cast_nullable_to_non_nullable + as SlotConfigurationOptions?, + )); + } +} + +/// @nodoc + +@JsonSerializable(explicitToJson: true, includeIfNull: false) +class _$SlotConfigurationHotpImpl extends _SlotConfigurationHotp { + const _$SlotConfigurationHotpImpl( + {required this.key, this.options, final String? $type}) + : $type = $type ?? 'hotp', + super._(); + + factory _$SlotConfigurationHotpImpl.fromJson(Map json) => + _$$SlotConfigurationHotpImplFromJson(json); + + @override + final String key; + @override + final SlotConfigurationOptions? options; + + @JsonKey(name: 'type') + final String $type; + + @override + String toString() { + return 'SlotConfiguration.hotp(key: $key, options: $options)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SlotConfigurationHotpImpl && + (identical(other.key, key) || other.key == key) && + (identical(other.options, options) || other.options == options)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, key, options); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SlotConfigurationHotpImplCopyWith<_$SlotConfigurationHotpImpl> + get copyWith => __$$SlotConfigurationHotpImplCopyWithImpl< + _$SlotConfigurationHotpImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String key, SlotConfigurationOptions? options) + hotp, + required TResult Function(String key, SlotConfigurationOptions? options) + chalresp, + required TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options) + static, + required TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options) + yubiotp, + }) { + return hotp(key, options); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String key, SlotConfigurationOptions? options)? hotp, + TResult? Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult? Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult? Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + }) { + return hotp?.call(key, options); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String key, SlotConfigurationOptions? options)? hotp, + TResult Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + required TResult orElse(), + }) { + if (hotp != null) { + return hotp(key, options); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_SlotConfigurationHotp value) hotp, + required TResult Function(_SlotConfigurationHmacSha1 value) chalresp, + required TResult Function(_SlotConfigurationStaticPassword value) static, + required TResult Function(_SlotConfigurationYubiOtp value) yubiotp, + }) { + return hotp(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_SlotConfigurationHotp value)? hotp, + TResult? Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult? Function(_SlotConfigurationStaticPassword value)? static, + TResult? Function(_SlotConfigurationYubiOtp value)? yubiotp, + }) { + return hotp?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_SlotConfigurationHotp value)? hotp, + TResult Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult Function(_SlotConfigurationStaticPassword value)? static, + TResult Function(_SlotConfigurationYubiOtp value)? yubiotp, + required TResult orElse(), + }) { + if (hotp != null) { + return hotp(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$SlotConfigurationHotpImplToJson( + this, + ); + } +} + +abstract class _SlotConfigurationHotp extends SlotConfiguration { + const factory _SlotConfigurationHotp( + {required final String key, + final SlotConfigurationOptions? options}) = _$SlotConfigurationHotpImpl; + const _SlotConfigurationHotp._() : super._(); + + factory _SlotConfigurationHotp.fromJson(Map json) = + _$SlotConfigurationHotpImpl.fromJson; + + String get key; + @override + SlotConfigurationOptions? get options; + @override + @JsonKey(ignore: true) + _$$SlotConfigurationHotpImplCopyWith<_$SlotConfigurationHotpImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$SlotConfigurationHmacSha1ImplCopyWith<$Res> + implements $SlotConfigurationCopyWith<$Res> { + factory _$$SlotConfigurationHmacSha1ImplCopyWith( + _$SlotConfigurationHmacSha1Impl value, + $Res Function(_$SlotConfigurationHmacSha1Impl) then) = + __$$SlotConfigurationHmacSha1ImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String key, SlotConfigurationOptions? options}); + + @override + $SlotConfigurationOptionsCopyWith<$Res>? get options; +} + +/// @nodoc +class __$$SlotConfigurationHmacSha1ImplCopyWithImpl<$Res> + extends _$SlotConfigurationCopyWithImpl<$Res, + _$SlotConfigurationHmacSha1Impl> + implements _$$SlotConfigurationHmacSha1ImplCopyWith<$Res> { + __$$SlotConfigurationHmacSha1ImplCopyWithImpl( + _$SlotConfigurationHmacSha1Impl _value, + $Res Function(_$SlotConfigurationHmacSha1Impl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? key = null, + Object? options = freezed, + }) { + return _then(_$SlotConfigurationHmacSha1Impl( + key: null == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as String, + options: freezed == options + ? _value.options + : options // ignore: cast_nullable_to_non_nullable + as SlotConfigurationOptions?, + )); + } +} + +/// @nodoc + +@JsonSerializable(explicitToJson: true, includeIfNull: false) +class _$SlotConfigurationHmacSha1Impl extends _SlotConfigurationHmacSha1 { + const _$SlotConfigurationHmacSha1Impl( + {required this.key, this.options, final String? $type}) + : $type = $type ?? 'hmac_sha1', + super._(); + + factory _$SlotConfigurationHmacSha1Impl.fromJson(Map json) => + _$$SlotConfigurationHmacSha1ImplFromJson(json); + + @override + final String key; + @override + final SlotConfigurationOptions? options; + + @JsonKey(name: 'type') + final String $type; + + @override + String toString() { + return 'SlotConfiguration.chalresp(key: $key, options: $options)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SlotConfigurationHmacSha1Impl && + (identical(other.key, key) || other.key == key) && + (identical(other.options, options) || other.options == options)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, key, options); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SlotConfigurationHmacSha1ImplCopyWith<_$SlotConfigurationHmacSha1Impl> + get copyWith => __$$SlotConfigurationHmacSha1ImplCopyWithImpl< + _$SlotConfigurationHmacSha1Impl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String key, SlotConfigurationOptions? options) + hotp, + required TResult Function(String key, SlotConfigurationOptions? options) + chalresp, + required TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options) + static, + required TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options) + yubiotp, + }) { + return chalresp(key, options); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String key, SlotConfigurationOptions? options)? hotp, + TResult? Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult? Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult? Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + }) { + return chalresp?.call(key, options); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String key, SlotConfigurationOptions? options)? hotp, + TResult Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + required TResult orElse(), + }) { + if (chalresp != null) { + return chalresp(key, options); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_SlotConfigurationHotp value) hotp, + required TResult Function(_SlotConfigurationHmacSha1 value) chalresp, + required TResult Function(_SlotConfigurationStaticPassword value) static, + required TResult Function(_SlotConfigurationYubiOtp value) yubiotp, + }) { + return chalresp(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_SlotConfigurationHotp value)? hotp, + TResult? Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult? Function(_SlotConfigurationStaticPassword value)? static, + TResult? Function(_SlotConfigurationYubiOtp value)? yubiotp, + }) { + return chalresp?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_SlotConfigurationHotp value)? hotp, + TResult Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult Function(_SlotConfigurationStaticPassword value)? static, + TResult Function(_SlotConfigurationYubiOtp value)? yubiotp, + required TResult orElse(), + }) { + if (chalresp != null) { + return chalresp(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$SlotConfigurationHmacSha1ImplToJson( + this, + ); + } +} + +abstract class _SlotConfigurationHmacSha1 extends SlotConfiguration { + const factory _SlotConfigurationHmacSha1( + {required final String key, + final SlotConfigurationOptions? options}) = + _$SlotConfigurationHmacSha1Impl; + const _SlotConfigurationHmacSha1._() : super._(); + + factory _SlotConfigurationHmacSha1.fromJson(Map json) = + _$SlotConfigurationHmacSha1Impl.fromJson; + + String get key; + @override + SlotConfigurationOptions? get options; + @override + @JsonKey(ignore: true) + _$$SlotConfigurationHmacSha1ImplCopyWith<_$SlotConfigurationHmacSha1Impl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$SlotConfigurationStaticPasswordImplCopyWith<$Res> + implements $SlotConfigurationCopyWith<$Res> { + factory _$$SlotConfigurationStaticPasswordImplCopyWith( + _$SlotConfigurationStaticPasswordImpl value, + $Res Function(_$SlotConfigurationStaticPasswordImpl) then) = + __$$SlotConfigurationStaticPasswordImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String password, + String keyboardLayout, + SlotConfigurationOptions? options}); + + @override + $SlotConfigurationOptionsCopyWith<$Res>? get options; +} + +/// @nodoc +class __$$SlotConfigurationStaticPasswordImplCopyWithImpl<$Res> + extends _$SlotConfigurationCopyWithImpl<$Res, + _$SlotConfigurationStaticPasswordImpl> + implements _$$SlotConfigurationStaticPasswordImplCopyWith<$Res> { + __$$SlotConfigurationStaticPasswordImplCopyWithImpl( + _$SlotConfigurationStaticPasswordImpl _value, + $Res Function(_$SlotConfigurationStaticPasswordImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? password = null, + Object? keyboardLayout = null, + Object? options = freezed, + }) { + return _then(_$SlotConfigurationStaticPasswordImpl( + password: null == password + ? _value.password + : password // ignore: cast_nullable_to_non_nullable + as String, + keyboardLayout: null == keyboardLayout + ? _value.keyboardLayout + : keyboardLayout // ignore: cast_nullable_to_non_nullable + as String, + options: freezed == options + ? _value.options + : options // ignore: cast_nullable_to_non_nullable + as SlotConfigurationOptions?, + )); + } +} + +/// @nodoc + +@JsonSerializable(explicitToJson: true, includeIfNull: false) +class _$SlotConfigurationStaticPasswordImpl + extends _SlotConfigurationStaticPassword { + const _$SlotConfigurationStaticPasswordImpl( + {required this.password, + required this.keyboardLayout, + this.options, + final String? $type}) + : $type = $type ?? 'static_password', + super._(); + + factory _$SlotConfigurationStaticPasswordImpl.fromJson( + Map json) => + _$$SlotConfigurationStaticPasswordImplFromJson(json); + + @override + final String password; + @override + final String keyboardLayout; + @override + final SlotConfigurationOptions? options; + + @JsonKey(name: 'type') + final String $type; + + @override + String toString() { + return 'SlotConfiguration.static(password: $password, keyboardLayout: $keyboardLayout, options: $options)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SlotConfigurationStaticPasswordImpl && + (identical(other.password, password) || + other.password == password) && + (identical(other.keyboardLayout, keyboardLayout) || + other.keyboardLayout == keyboardLayout) && + (identical(other.options, options) || other.options == options)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => + Object.hash(runtimeType, password, keyboardLayout, options); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SlotConfigurationStaticPasswordImplCopyWith< + _$SlotConfigurationStaticPasswordImpl> + get copyWith => __$$SlotConfigurationStaticPasswordImplCopyWithImpl< + _$SlotConfigurationStaticPasswordImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String key, SlotConfigurationOptions? options) + hotp, + required TResult Function(String key, SlotConfigurationOptions? options) + chalresp, + required TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options) + static, + required TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options) + yubiotp, + }) { + return static(password, keyboardLayout, options); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String key, SlotConfigurationOptions? options)? hotp, + TResult? Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult? Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult? Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + }) { + return static?.call(password, keyboardLayout, options); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String key, SlotConfigurationOptions? options)? hotp, + TResult Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + required TResult orElse(), + }) { + if (static != null) { + return static(password, keyboardLayout, options); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_SlotConfigurationHotp value) hotp, + required TResult Function(_SlotConfigurationHmacSha1 value) chalresp, + required TResult Function(_SlotConfigurationStaticPassword value) static, + required TResult Function(_SlotConfigurationYubiOtp value) yubiotp, + }) { + return static(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_SlotConfigurationHotp value)? hotp, + TResult? Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult? Function(_SlotConfigurationStaticPassword value)? static, + TResult? Function(_SlotConfigurationYubiOtp value)? yubiotp, + }) { + return static?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_SlotConfigurationHotp value)? hotp, + TResult Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult Function(_SlotConfigurationStaticPassword value)? static, + TResult Function(_SlotConfigurationYubiOtp value)? yubiotp, + required TResult orElse(), + }) { + if (static != null) { + return static(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$SlotConfigurationStaticPasswordImplToJson( + this, + ); + } +} + +abstract class _SlotConfigurationStaticPassword extends SlotConfiguration { + const factory _SlotConfigurationStaticPassword( + {required final String password, + required final String keyboardLayout, + final SlotConfigurationOptions? options}) = + _$SlotConfigurationStaticPasswordImpl; + const _SlotConfigurationStaticPassword._() : super._(); + + factory _SlotConfigurationStaticPassword.fromJson(Map json) = + _$SlotConfigurationStaticPasswordImpl.fromJson; + + String get password; + String get keyboardLayout; + @override + SlotConfigurationOptions? get options; + @override + @JsonKey(ignore: true) + _$$SlotConfigurationStaticPasswordImplCopyWith< + _$SlotConfigurationStaticPasswordImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$SlotConfigurationYubiOtpImplCopyWith<$Res> + implements $SlotConfigurationCopyWith<$Res> { + factory _$$SlotConfigurationYubiOtpImplCopyWith( + _$SlotConfigurationYubiOtpImpl value, + $Res Function(_$SlotConfigurationYubiOtpImpl) then) = + __$$SlotConfigurationYubiOtpImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String publicId, + String privateId, + String key, + SlotConfigurationOptions? options}); + + @override + $SlotConfigurationOptionsCopyWith<$Res>? get options; +} + +/// @nodoc +class __$$SlotConfigurationYubiOtpImplCopyWithImpl<$Res> + extends _$SlotConfigurationCopyWithImpl<$Res, + _$SlotConfigurationYubiOtpImpl> + implements _$$SlotConfigurationYubiOtpImplCopyWith<$Res> { + __$$SlotConfigurationYubiOtpImplCopyWithImpl( + _$SlotConfigurationYubiOtpImpl _value, + $Res Function(_$SlotConfigurationYubiOtpImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? publicId = null, + Object? privateId = null, + Object? key = null, + Object? options = freezed, + }) { + return _then(_$SlotConfigurationYubiOtpImpl( + publicId: null == publicId + ? _value.publicId + : publicId // ignore: cast_nullable_to_non_nullable + as String, + privateId: null == privateId + ? _value.privateId + : privateId // ignore: cast_nullable_to_non_nullable + as String, + key: null == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as String, + options: freezed == options + ? _value.options + : options // ignore: cast_nullable_to_non_nullable + as SlotConfigurationOptions?, + )); + } +} + +/// @nodoc + +@JsonSerializable(explicitToJson: true, includeIfNull: false) +class _$SlotConfigurationYubiOtpImpl extends _SlotConfigurationYubiOtp { + const _$SlotConfigurationYubiOtpImpl( + {required this.publicId, + required this.privateId, + required this.key, + this.options, + final String? $type}) + : $type = $type ?? 'yubiotp', + super._(); + + factory _$SlotConfigurationYubiOtpImpl.fromJson(Map json) => + _$$SlotConfigurationYubiOtpImplFromJson(json); + + @override + final String publicId; + @override + final String privateId; + @override + final String key; + @override + final SlotConfigurationOptions? options; + + @JsonKey(name: 'type') + final String $type; + + @override + String toString() { + return 'SlotConfiguration.yubiotp(publicId: $publicId, privateId: $privateId, key: $key, options: $options)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SlotConfigurationYubiOtpImpl && + (identical(other.publicId, publicId) || + other.publicId == publicId) && + (identical(other.privateId, privateId) || + other.privateId == privateId) && + (identical(other.key, key) || other.key == key) && + (identical(other.options, options) || other.options == options)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => + Object.hash(runtimeType, publicId, privateId, key, options); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SlotConfigurationYubiOtpImplCopyWith<_$SlotConfigurationYubiOtpImpl> + get copyWith => __$$SlotConfigurationYubiOtpImplCopyWithImpl< + _$SlotConfigurationYubiOtpImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String key, SlotConfigurationOptions? options) + hotp, + required TResult Function(String key, SlotConfigurationOptions? options) + chalresp, + required TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options) + static, + required TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options) + yubiotp, + }) { + return yubiotp(publicId, privateId, key, options); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String key, SlotConfigurationOptions? options)? hotp, + TResult? Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult? Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult? Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + }) { + return yubiotp?.call(publicId, privateId, key, options); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String key, SlotConfigurationOptions? options)? hotp, + TResult Function(String key, SlotConfigurationOptions? options)? chalresp, + TResult Function(String password, String keyboardLayout, + SlotConfigurationOptions? options)? + static, + TResult Function(String publicId, String privateId, String key, + SlotConfigurationOptions? options)? + yubiotp, + required TResult orElse(), + }) { + if (yubiotp != null) { + return yubiotp(publicId, privateId, key, options); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_SlotConfigurationHotp value) hotp, + required TResult Function(_SlotConfigurationHmacSha1 value) chalresp, + required TResult Function(_SlotConfigurationStaticPassword value) static, + required TResult Function(_SlotConfigurationYubiOtp value) yubiotp, + }) { + return yubiotp(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_SlotConfigurationHotp value)? hotp, + TResult? Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult? Function(_SlotConfigurationStaticPassword value)? static, + TResult? Function(_SlotConfigurationYubiOtp value)? yubiotp, + }) { + return yubiotp?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_SlotConfigurationHotp value)? hotp, + TResult Function(_SlotConfigurationHmacSha1 value)? chalresp, + TResult Function(_SlotConfigurationStaticPassword value)? static, + TResult Function(_SlotConfigurationYubiOtp value)? yubiotp, + required TResult orElse(), + }) { + if (yubiotp != null) { + return yubiotp(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$SlotConfigurationYubiOtpImplToJson( + this, + ); + } +} + +abstract class _SlotConfigurationYubiOtp extends SlotConfiguration { + const factory _SlotConfigurationYubiOtp( + {required final String publicId, + required final String privateId, + required final String key, + final SlotConfigurationOptions? options}) = + _$SlotConfigurationYubiOtpImpl; + const _SlotConfigurationYubiOtp._() : super._(); + + factory _SlotConfigurationYubiOtp.fromJson(Map json) = + _$SlotConfigurationYubiOtpImpl.fromJson; + + String get publicId; + String get privateId; + String get key; + @override + SlotConfigurationOptions? get options; + @override + @JsonKey(ignore: true) + _$$SlotConfigurationYubiOtpImplCopyWith<_$SlotConfigurationYubiOtpImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/otp/models.g.dart b/lib/otp/models.g.dart new file mode 100644 index 00000000..5d8e80e8 --- /dev/null +++ b/lib/otp/models.g.dart @@ -0,0 +1,161 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$OtpStateImpl _$$OtpStateImplFromJson(Map json) => + _$OtpStateImpl( + slot1Configured: json['slot1_configured'] as bool, + slot2Configured: json['slot2_configured'] as bool, + ); + +Map _$$OtpStateImplToJson(_$OtpStateImpl instance) => + { + 'slot1_configured': instance.slot1Configured, + 'slot2_configured': instance.slot2Configured, + }; + +_$SlotConfigurationOptionsImpl _$$SlotConfigurationOptionsImplFromJson( + Map json) => + _$SlotConfigurationOptionsImpl( + digits8: json['digits8'] as bool?, + requireTouch: json['require_touch'] as bool?, + appendCr: json['append_cr'] as bool?, + ); + +Map _$$SlotConfigurationOptionsImplToJson( + _$SlotConfigurationOptionsImpl instance) { + final val = {}; + + 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 json) => + _$SlotConfigurationHotpImpl( + key: json['key'] as String, + options: json['options'] == null + ? null + : SlotConfigurationOptions.fromJson( + json['options'] as Map), + $type: json['type'] as String?, + ); + +Map _$$SlotConfigurationHotpImplToJson( + _$SlotConfigurationHotpImpl instance) { + final val = { + '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 json) => + _$SlotConfigurationHmacSha1Impl( + key: json['key'] as String, + options: json['options'] == null + ? null + : SlotConfigurationOptions.fromJson( + json['options'] as Map), + $type: json['type'] as String?, + ); + +Map _$$SlotConfigurationHmacSha1ImplToJson( + _$SlotConfigurationHmacSha1Impl instance) { + final val = { + '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 json) => + _$SlotConfigurationStaticPasswordImpl( + password: json['password'] as String, + keyboardLayout: json['keyboard_layout'] as String, + options: json['options'] == null + ? null + : SlotConfigurationOptions.fromJson( + json['options'] as Map), + $type: json['type'] as String?, + ); + +Map _$$SlotConfigurationStaticPasswordImplToJson( + _$SlotConfigurationStaticPasswordImpl instance) { + final val = { + '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 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), + $type: json['type'] as String?, + ); + +Map _$$SlotConfigurationYubiOtpImplToJson( + _$SlotConfigurationYubiOtpImpl instance) { + final val = { + '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; +} diff --git a/lib/otp/state.dart b/lib/otp/state.dart new file mode 100644 index 00000000..ccdfb15d --- /dev/null +++ b/lib/otp/state.dart @@ -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( + (ref) => YubiOtpOutputNotifier()); + +class YubiOtpOutputNotifier extends StateNotifier { + YubiOtpOutputNotifier() : super(null); + + void setOutput(File? file) { + state = file; + } +} + +final otpStateProvider = AsyncNotifierProvider.autoDispose + .family( + () => throw UnimplementedError(), +); + +abstract class OtpStateNotifier extends ApplicationStateNotifier { + Future generateStaticPassword(int length, String layout); + Future modhexEncodeSerial(int serial); + Future>> getKeyboardLayouts(); + Future formatYubiOtpCsv( + int serial, String publicId, String privateId, String key); + Future swapSlots(); + Future configureSlot(SlotId slot, + {required SlotConfiguration configuration}); + Future deleteSlot(SlotId slot); +} diff --git a/lib/otp/views/actions.dart b/lib/otp/views/actions.dart new file mode 100644 index 00000000..a96d65cc --- /dev/null +++ b/lib/otp/views/actions.dart @@ -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> actions = const {}, +}) { + final hasFeature = ref.watch(featureProvider); + return Actions( + actions: { + if (hasFeature(features.slotsConfigureChalResp)) + ConfigureChalRespIntent: + CallbackAction(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(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(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(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(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 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, + ) + ]; +} diff --git a/lib/otp/views/configure_chalresp_dialog.dart b/lib/otp/views/configure_chalresp_dialog.dart new file mode 100644 index 00000000..d75d5325 --- /dev/null +++ b/lib/otp/views/configure_chalresp_dialog.dart @@ -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 createState() => + _ConfigureChalrespDialogState(); +} + +class _ConfigureChalrespDialogState + extends ConsumerState { + 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(), + ), + ), + ); + } +} diff --git a/lib/otp/views/configure_hotp_dialog.dart b/lib/otp/views/configure_hotp_dialog.dart new file mode 100644 index 00000000..4ef1c3b5 --- /dev/null +++ b/lib/otp/views/configure_hotp_dialog.dart @@ -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 createState() => + _ConfigureHotpDialogState(); +} + +class _ConfigureHotpDialogState extends ConsumerState { + final _secretController = TextEditingController(); + bool _validateSecret = false; + int _digits = defaultDigits; + final List _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( + 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(), + ), + ), + ); + } +} diff --git a/lib/otp/views/configure_static_dialog.dart b/lib/otp/views/configure_static_dialog.dart new file mode 100644 index 00000000..6c383caa --- /dev/null +++ b/lib/otp/views/configure_static_dialog.dart @@ -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> keyboardLayouts; + const ConfigureStaticDialog( + this.devicePath, this.otpSlot, this.keyboardLayouts, + {super.key}); + + @override + ConsumerState createState() => + _ConfigureStaticDialogState(); +} + +class _ConfigureStaticDialogState extends ConsumerState { + 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(), + ), + ), + ); + } +} diff --git a/lib/otp/views/configure_yubiotp_dialog.dart b/lib/otp/views/configure_yubiotp_dialog.dart new file mode 100644 index 00000000..a3b4222b --- /dev/null +++ b/lib/otp/views/configure_yubiotp_dialog.dart @@ -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 createState() => + _ConfigureYubiOtpDialogState(); +} + +class _ConfigureYubiOtpDialogState + extends ConsumerState { + 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 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( + 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(), + ), + ), + ); + } +} diff --git a/lib/otp/views/delete_slot_dialog.dart b/lib/otp/views/delete_slot_dialog.dart new file mode 100644 index 00000000..0844d301 --- /dev/null +++ b/lib/otp/views/delete_slot_dialog.dart @@ -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(), + ), + ), + ); + } +} diff --git a/lib/otp/views/key_actions.dart b/lib/otp/views/key_actions.dart new file mode 100644 index 00000000..76b370e9 --- /dev/null +++ b/lib/otp/views/key_actions.dart @@ -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, + ) + ]) + ], + ), + ); +} diff --git a/lib/otp/views/otp_screen.dart b/lib/otp/views/otp_screen.dart new file mode 100644 index 00000000..44fbd390 --- /dev/null +++ b/lib/otp/views/otp_screen.dart @@ -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(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, + )); + } +} diff --git a/lib/otp/views/overwrite_confirm_dialog.dart b/lib/otp/views/overwrite_confirm_dialog.dart new file mode 100644 index 00000000..54de5436 --- /dev/null +++ b/lib/otp/views/overwrite_confirm_dialog.dart @@ -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 confirmOverwrite(BuildContext context, OtpSlot otpSlot) async { + if (otpSlot.isConfigured) { + return await showBlurDialog( + context: context, + builder: (context) => _OverwriteConfirmDialog( + otpSlot: otpSlot, + )) ?? + false; + } + return true; +} diff --git a/lib/otp/views/slot_dialog.dart b/lib/otp/views/slot_dialog.dart new file mode 100644 index 00000000..bc4ee4cd --- /dev/null +++ b/lib/otp/views/slot_dialog.dart @@ -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), + ) + ], + ), + ), + )); + } +} diff --git a/lib/otp/views/swap_slots_dialog.dart b/lib/otp/views/swap_slots_dialog.dart new file mode 100644 index 00000000..50334e72 --- /dev/null +++ b/lib/otp/views/swap_slots_dialog.dart @@ -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(), + ), + ), + ); + } +} diff --git a/lib/piv/views/authentication_dialog.dart b/lib/piv/views/authentication_dialog.dart index 503aedda..b527831b 100644 --- a/lib/piv/views/authentication_dialog.dart +++ b/lib/piv/views/authentication_dialog.dart @@ -15,12 +15,13 @@ */ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/models.dart'; +import '../../core/models.dart'; import '../../exception/cancellation_exception.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; @@ -40,6 +41,7 @@ class AuthenticationDialog extends ConsumerStatefulWidget { class _AuthenticationDialogState extends ConsumerState { bool _defaultKeyUsed = false; bool _keyIsWrong = false; + bool _keyFormatInvalid = false; final _keyController = TextEditingController(); @override @@ -56,6 +58,7 @@ class _AuthenticationDialogState extends ConsumerState { ManagementKeyType.tdes) .keyLength * 2; + final keyFormatInvalid = !Format.hex.isValid(_keyController.text); return ResponsiveDialog( title: Text(l10n.l_unlock_piv_management), actions: [ @@ -63,6 +66,12 @@ class _AuthenticationDialogState extends ConsumerState { key: keys.unlockButton, onPressed: _keyController.text.length == keyLen ? () async { + if (keyFormatInvalid) { + setState(() { + _keyFormatInvalid = true; + }); + return; + } final navigator = Navigator.of(context); try { final status = await ref @@ -99,19 +108,20 @@ class _AuthenticationDialogState extends ConsumerState { autofocus: true, autofillHints: const [AutofillHints.password], controller: _keyController, - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp('[a-f0-9]', caseSensitive: false)) - ], readOnly: _defaultKeyUsed, maxLength: !_defaultKeyUsed ? keyLen : null, - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), 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, + 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 ? null : IconButton( @@ -121,6 +131,7 @@ class _AuthenticationDialogState extends ConsumerState { tooltip: l10n.s_use_default, onPressed: () { setState(() { + _keyFormatInvalid = false; _defaultKeyUsed = !_defaultKeyUsed; if (_defaultKeyUsed) { _keyController.text = defaultManagementKey; @@ -135,6 +146,7 @@ class _AuthenticationDialogState extends ConsumerState { onChanged: (value) { setState(() { _keyIsWrong = false; + _keyFormatInvalid = false; }); }, ), diff --git a/lib/piv/views/generate_key_dialog.dart b/lib/piv/views/generate_key_dialog.dart index 8afc3fd9..a2afc332 100644 --- a/lib/piv/views/generate_key_dialog.dart +++ b/lib/piv/views/generate_key_dialog.dart @@ -22,6 +22,7 @@ import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../core/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'; @@ -161,12 +162,13 @@ class _GenerateKeyDialogState extends ConsumerState { AppTextField( autofocus: true, key: keys.subjectField, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.s_subject, - errorText: _subject.isNotEmpty && _invalidSubject - ? l10n.l_rfc4514_invalid - : null), + decoration: AppInputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_subject, + errorText: _subject.isNotEmpty && _invalidSubject + ? l10n.l_rfc4514_invalid + : null, + ), textInputAction: TextInputAction.next, enabled: !_generating, onChanged: (value) { diff --git a/lib/piv/views/import_file_dialog.dart b/lib/piv/views/import_file_dialog.dart index a6020186..2954f499 100644 --- a/lib/piv/views/import_file_dialog.dart +++ b/lib/piv/views/import_file_dialog.dart @@ -23,6 +23,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; @@ -51,6 +52,7 @@ class _ImportFileDialogState extends ConsumerState { String _password = ''; bool _passwordIsWrong = false; bool _importing = false; + bool _isObscure = true; @override void initState() { @@ -125,15 +127,27 @@ class _ImportFileDialogState extends ConsumerState { Text(l10n.p_password_protected_file), AppTextField( autofocus: true, - obscureText: true, + obscureText: _isObscure, autofillHints: const [AutofillHints.password], key: keys.managementKeyField, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.s_password, - prefixIcon: const Icon(Icons.password_outlined), - errorText: _passwordIsWrong ? l10n.s_wrong_password : null, - errorMaxLines: 3), + decoration: AppInputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_password, + errorText: _passwordIsWrong ? l10n.s_wrong_password : null, + 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, onChanged: (value) { setState(() { diff --git a/lib/piv/views/manage_key_dialog.dart b/lib/piv/views/manage_key_dialog.dart index 2402aa26..33bd3aa8 100644 --- a/lib/piv/views/manage_key_dialog.dart +++ b/lib/piv/views/manage_key_dialog.dart @@ -17,13 +17,14 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.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_form_field.dart'; import '../../widgets/choice_filter_chip.dart'; @@ -49,10 +50,13 @@ class _ManageKeyDialogState extends ConsumerState { late bool _usesStoredKey; late bool _storeKey; bool _currentIsWrong = false; + bool _currentInvalidFormat = false; + bool _newInvalidFormat = false; int _attemptsRemaining = -1; ManagementKeyType _keyType = ManagementKeyType.tdes; final _currentController = TextEditingController(); final _keyController = TextEditingController(); + bool _isObscure = true; @override void initState() { @@ -76,6 +80,16 @@ class _ManageKeyDialogState extends ConsumerState { } _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); if (_usesStoredKey) { final status = (await notifier.verifyPin(_currentController.text)).when( @@ -155,24 +169,37 @@ class _ManageKeyDialogState extends ConsumerState { if (protected) AppTextField( autofocus: true, - obscureText: true, + obscureText: _isObscure, autofillHints: const [AutofillHints.password], key: keys.pinPukField, maxLength: 8, controller: _currentController, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.s_pin, - prefixIcon: const Icon(Icons.pin_outlined), - errorText: _currentIsWrong - ? l10n - .l_wrong_pin_attempts_remaining(_attemptsRemaining) - : null, - errorMaxLines: 3), + decoration: AppInputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_pin, + errorText: _currentIsWrong + ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining) + : _currentInvalidFormat + ? l10n.l_invalid_format_allowed_chars( + Format.hex.allowedCharacters) + : 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, onChanged: (value) { setState(() { _currentIsWrong = false; + _currentInvalidFormat = false; }); }, ), @@ -184,13 +211,18 @@ class _ManageKeyDialogState extends ConsumerState { controller: _currentController, readOnly: _defaultKeyUsed, maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null, - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), 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, + 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 ? null : IconButton( @@ -210,10 +242,6 @@ class _ManageKeyDialogState extends ConsumerState { }, ), ), - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp('[a-f0-9]', caseSensitive: false)) - ], textInputAction: TextInputAction.next, onChanged: (value) { setState(() { @@ -227,15 +255,15 @@ class _ManageKeyDialogState extends ConsumerState { autofillHints: const [AutofillHints.newPassword], maxLength: hexLength, controller: _keyController, - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp('[a-f0-9]', caseSensitive: false)) - ], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), 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, + prefixIcon: const Icon(Icons.key_outlined), suffixIcon: IconButton( key: keys.managementKeyRefresh, icon: const Icon(Icons.refresh), @@ -251,6 +279,7 @@ class _ManageKeyDialogState extends ConsumerState { .padLeft(2, '0')).join(); setState(() { _keyController.text = key; + _newInvalidFormat = false; }); } : null, diff --git a/lib/piv/views/manage_pin_puk_dialog.dart b/lib/piv/views/manage_pin_puk_dialog.dart index 5131cde0..d25571ad 100644 --- a/lib/piv/views/manage_pin_puk_dialog.dart +++ b/lib/piv/views/manage_pin_puk_dialog.dart @@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; @@ -44,6 +45,9 @@ class _ManagePinPukDialogState extends ConsumerState { String _confirmPin = ''; bool _currentIsWrong = false; int _attemptsRemaining = -1; + bool _isObscureCurrent = true; + bool _isObscureNew = true; + bool _isObscureConfirm = true; _submit() async { final notifier = ref.read(pivStateProvider(widget.path).notifier); @@ -104,24 +108,38 @@ class _ManagePinPukDialogState extends ConsumerState { : l10n.p_enter_current_puk_or_reset), AppTextField( autofocus: true, - obscureText: true, + obscureText: _isObscureCurrent, maxLength: 8, autofillHints: const [AutofillHints.password], key: keys.pinPukField, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: widget.target == ManageTarget.pin - ? l10n.s_current_pin - : l10n.s_current_puk, - prefixIcon: const Icon(Icons.password_outlined), - errorText: _currentIsWrong - ? (widget.target == ManageTarget.pin - ? l10n.l_wrong_pin_attempts_remaining( - _attemptsRemaining) - : l10n.l_wrong_puk_attempts_remaining( - _attemptsRemaining)) - : null, - errorMaxLines: 3), + decoration: AppInputDecoration( + border: const OutlineInputBorder(), + labelText: widget.target == ManageTarget.pin + ? l10n.s_current_pin + : l10n.s_current_puk, + errorText: _currentIsWrong + ? (widget.target == ManageTarget.pin + ? l10n + .l_wrong_pin_attempts_remaining(_attemptsRemaining) + : l10n + .l_wrong_puk_attempts_remaining(_attemptsRemaining)) + : null, + errorMaxLines: 3, + prefixIcon: const Icon(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, onChanged: (value) { setState(() { @@ -134,15 +152,27 @@ class _ManagePinPukDialogState extends ConsumerState { widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)), AppTextField( key: keys.newPinPukField, - obscureText: true, + obscureText: _isObscureNew, maxLength: 8, autofillHints: const [AutofillHints.newPassword], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: widget.target == ManageTarget.puk ? l10n.s_new_puk : l10n.s_new_pin, 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 enabled: _currentPin.length >= 4, ), @@ -160,15 +190,28 @@ class _ManagePinPukDialogState extends ConsumerState { ), AppTextField( key: keys.confirmPinPukField, - obscureText: true, + obscureText: _isObscureConfirm, maxLength: 8, autofillHints: const [AutofillHints.newPassword], - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: widget.target == ManageTarget.puk ? l10n.s_confirm_puk : l10n.s_confirm_pin, 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, ), textInputAction: TextInputAction.done, diff --git a/lib/piv/views/pin_dialog.dart b/lib/piv/views/pin_dialog.dart index 25edcd25..0181121c 100644 --- a/lib/piv/views/pin_dialog.dart +++ b/lib/piv/views/pin_dialog.dart @@ -20,6 +20,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/models.dart'; import '../../exception/cancellation_exception.dart'; +import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/responsive_dialog.dart'; import '../keys.dart' as keys; @@ -93,19 +94,18 @@ class _PinDialogState extends ConsumerState { autofillHints: const [AutofillHints.password], key: keys.managementKeyField, controller: _pinController, - decoration: InputDecoration( + decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, - prefixIcon: const Icon(Icons.pin_outlined), errorText: _pinIsWrong ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining) : null, errorMaxLines: 3, + prefixIcon: const Icon(Icons.pin_outlined), suffixIcon: IconButton( icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off, - color: IconTheme.of(context).color, - ), + _isObscure ? Icons.visibility : Icons.visibility_off, + color: !_pinIsWrong ? IconTheme.of(context).color : null), onPressed: () { setState(() { _isObscure = !_isObscure; diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index 4a1f31b7..3227d54b 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -46,18 +46,18 @@ class PivScreen extends ConsumerWidget { final hasFeature = ref.watch(featureProvider); return ref.watch(pivStateProvider(devicePath)).when( loading: () => MessagePage( - title: Text(l10n.s_piv), + title: Text(l10n.s_certificates), graphic: const CircularProgressIndicator(), delayedContent: true, ), error: (error, _) => AppFailurePage( - title: Text(l10n.s_piv), + title: Text(l10n.s_certificates), cause: error, ), data: (pivState) { final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData; return AppPage( - title: Text(l10n.s_piv), + title: Text(l10n.s_certificates), keyActionsBuilder: hasFeature(features.actions) ? (context) => pivBuildActions(context, devicePath, pivState, ref) diff --git a/lib/widgets/app_input_decoration.dart b/lib/widgets/app_input_decoration.dart new file mode 100644 index 00000000..a2cb922f --- /dev/null +++ b/lib/widgets/app_input_decoration.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +class AppInputDecoration extends InputDecoration { + final List? 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, + ) + ], + ); + }, + ), + }; + } +} diff --git a/lib/widgets/app_text_field.dart b/lib/widgets/app_text_field.dart index a4f70d1f..ff0c49e3 100644 --- a/lib/widgets/app_text_field.dart +++ b/lib/widgets/app_text_field.dart @@ -16,6 +16,8 @@ import 'package:flutter/material.dart'; +import 'app_input_decoration.dart'; + /// TextField without autocorrect and suggestions class AppTextField extends TextField { const AppTextField({ @@ -28,7 +30,7 @@ class AppTextField extends TextField { super.controller, super.focusNode, super.undoController, - super.decoration, + AppInputDecoration? decoration, super.textInputAction, super.textCapitalization, super.style, @@ -83,5 +85,5 @@ class AppTextField extends TextField { super.canRequestFocus, super.spellCheckConfiguration, super.magnifierConfiguration, - }); + }) : super(decoration: decoration); } diff --git a/lib/widgets/app_text_form_field.dart b/lib/widgets/app_text_form_field.dart index ea9020df..61e0540b 100644 --- a/lib/widgets/app_text_form_field.dart +++ b/lib/widgets/app_text_form_field.dart @@ -16,6 +16,8 @@ import 'package:flutter/material.dart'; +import 'app_input_decoration.dart'; + /// TextFormField without autocorrect and suggestions class AppTextFormField extends TextFormField { AppTextFormField({ @@ -28,7 +30,7 @@ class AppTextFormField extends TextFormField { super.controller, super.initialValue, super.focusNode, - super.decoration, + AppInputDecoration? decoration, super.textCapitalization, super.textInputAction, super.style, @@ -87,5 +89,5 @@ class AppTextFormField extends TextFormField { super.clipBehavior, super.scribbleEnabled, super.canRequestFocus, - }); + }) : super(decoration: decoration); } diff --git a/lib/widgets/choice_filter_chip.dart b/lib/widgets/choice_filter_chip.dart index 0b8c14d5..253785e0 100755 --- a/lib/widgets/choice_filter_chip.dart +++ b/lib/widgets/choice_filter_chip.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; class ChoiceFilterChip extends StatefulWidget { final T value; final List items; + final String? tooltip; final Widget Function(T value) itemBuilder; final Widget Function(T value)? labelBuilder; final void Function(T value)? onChanged; @@ -32,6 +33,7 @@ class ChoiceFilterChip extends StatefulWidget { required this.items, required this.itemBuilder, required this.onChanged, + this.tooltip, this.avatar, this.selected = false, this.labelBuilder, @@ -57,7 +59,6 @@ class _ChoiceFilterChipState extends State> { ), Offset.zero & overlay.size, ); - return await showMenu( context: context, position: position, @@ -79,6 +80,7 @@ class _ChoiceFilterChipState extends State> { @override Widget build(BuildContext context) { return FilterChip( + tooltip: widget.tooltip, avatar: widget.avatar, labelPadding: const EdgeInsets.only(left: 4), label: Row(