mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-26 10:33:15 +03:00
Merge PR #1302
This commit is contained in:
commit
624f4aae4f
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -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,
|
||||
|
34
integration_test/otp_test.dart
Normal file
34
integration_test/otp_test.dart
Normal file
@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@Tags(['android', 'desktop', 'oath'])
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:yubico_authenticator/app/views/keys.dart';
|
||||
|
||||
import 'utils/test_util.dart';
|
||||
|
||||
void main() {
|
||||
var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
|
||||
group('OTP UI tests', () {
|
||||
appTest('OTP menu items exist', (WidgetTester tester) async {
|
||||
await tester.tap(find.byKey(otpAppDrawer));
|
||||
await tester.shortWait();
|
||||
});
|
||||
});
|
||||
}
|
@ -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();
|
||||
|
@ -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),
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -155,3 +155,18 @@ class Version with _$Version implements Comparable<Version> {
|
||||
}
|
||||
|
||||
final DateFormat dateFormatter = DateFormat('yyyy-MM-dd');
|
||||
|
||||
enum Format {
|
||||
base32('a-z2-7'),
|
||||
hex('abcdef0123456789'),
|
||||
modhex('cbdefghijklnrtuv');
|
||||
|
||||
final String allowedCharacters;
|
||||
|
||||
const Format(this.allowedCharacters);
|
||||
|
||||
bool isValid(String input) {
|
||||
return RegExp('^[$allowedCharacters]+\$', caseSensitive: false)
|
||||
.hasMatch(input);
|
||||
}
|
||||
}
|
||||
|
@ -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<Widget> initialize(List<String> argv) async {
|
||||
Application.fido,
|
||||
Application.piv,
|
||||
Application.management,
|
||||
Application.otp
|
||||
])),
|
||||
prefProvider.overrideWithValue(prefs),
|
||||
rpcProvider.overrideWith((_) => rpcFuture),
|
||||
@ -226,6 +229,8 @@ Future<Widget> initialize(List<String> argv) async {
|
||||
// PIV
|
||||
pivStateProvider.overrideWithProvider(desktopPivState.call),
|
||||
pivSlotsProvider.overrideWithProvider(desktopPivSlots.call),
|
||||
// OTP
|
||||
otpStateProvider.overrideWithProvider(desktopOtpState.call)
|
||||
],
|
||||
child: YubicoAuthenticatorApp(
|
||||
page: Consumer(
|
||||
|
132
lib/desktop/otp/state.dart
Normal file
132
lib/desktop/otp/state.dart
Normal file
@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../otp/models.dart';
|
||||
import '../../otp/state.dart';
|
||||
import '../rpc.dart';
|
||||
import '../state.dart';
|
||||
|
||||
final _log = Logger('desktop.otp.state');
|
||||
|
||||
final _sessionProvider =
|
||||
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
|
||||
(ref, devicePath) =>
|
||||
RpcNodeSession(ref.watch(rpcProvider).requireValue, devicePath, []),
|
||||
);
|
||||
|
||||
final desktopOtpState = AsyncNotifierProvider.autoDispose
|
||||
.family<OtpStateNotifier, OtpState, DevicePath>(
|
||||
_DesktopOtpStateNotifier.new);
|
||||
|
||||
class _DesktopOtpStateNotifier extends OtpStateNotifier {
|
||||
late RpcNodeSession _session;
|
||||
List<String> _subpath = [];
|
||||
|
||||
@override
|
||||
FutureOr<OtpState> build(DevicePath devicePath) async {
|
||||
_session = ref.watch(_sessionProvider(devicePath));
|
||||
_session.setErrorHandler('state-reset', (_) async {
|
||||
ref.invalidate(_sessionProvider(devicePath));
|
||||
});
|
||||
ref.onDispose(() {
|
||||
_session.unsetErrorHandler('state-reset');
|
||||
});
|
||||
|
||||
final result = await _session.command('get');
|
||||
final interfaces = (result['children'] as Map).keys.toSet();
|
||||
|
||||
// Will try to connect over ccid first
|
||||
for (final iface in [UsbInterface.otp, UsbInterface.ccid]) {
|
||||
if (interfaces.contains(iface.name)) {
|
||||
final path = [iface.name, 'yubiotp'];
|
||||
try {
|
||||
final otpStateResult = await _session.command('get', target: path);
|
||||
_subpath = path;
|
||||
_log.debug('Using transport $iface for yubiotp');
|
||||
_log.debug('application status', jsonEncode(result));
|
||||
return OtpState.fromJson(otpStateResult['data']);
|
||||
} catch (e) {
|
||||
_log.warning('Failed connecting to yubiotp via $iface');
|
||||
}
|
||||
}
|
||||
}
|
||||
throw 'Failed connecting over ${UsbInterface.ccid.name} and ${UsbInterface.otp.name}';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> swapSlots() async {
|
||||
await _session.command('swap', target: _subpath);
|
||||
ref.invalidate(_sessionProvider(_session.devicePath));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> generateStaticPassword(int length, String layout) async {
|
||||
final result = await _session.command('generate_static',
|
||||
target: _subpath, params: {'length': length, 'layout': layout});
|
||||
return result['password'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> modhexEncodeSerial(int serial) async {
|
||||
final result = await _session
|
||||
.command('serial_modhex', target: _subpath, params: {'serial': serial});
|
||||
return result['encoded'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, List<String>>> getKeyboardLayouts() async {
|
||||
final result = await _session.command('keyboard_layouts', target: _subpath);
|
||||
return Map<String, List<String>>.from(result.map((key, value) =>
|
||||
MapEntry(key, (value as List<dynamic>).cast<String>().toList())));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> formatYubiOtpCsv(
|
||||
int serial, String publicId, String privateId, String key) async {
|
||||
final result = await _session.command('format_yubiotp_csv',
|
||||
target: _subpath,
|
||||
params: {
|
||||
'serial': serial,
|
||||
'public_id': publicId,
|
||||
'private_id': privateId,
|
||||
'key': key
|
||||
});
|
||||
return result['csv'];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSlot(SlotId slot) async {
|
||||
await _session.command('delete', target: [..._subpath, slot.id]);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> configureSlot(SlotId slot,
|
||||
{required SlotConfiguration configuration}) async {
|
||||
await _session.command('put',
|
||||
target: [..._subpath, slot.id], params: configuration.toJson());
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
}
|
@ -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<AddFingerprintDialog>
|
||||
inputFormatters: [limitBytesLength(15)],
|
||||
buildCounter: buildByteCounterFor(_label),
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
decoration: AppInputDecoration(
|
||||
enabled: _fingerprint != null,
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_name,
|
||||
|
@ -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;
|
||||
|
@ -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<FidoPinDialog> {
|
||||
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<FidoPinDialog> {
|
||||
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<FidoPinDialog> {
|
||||
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<FidoPinDialog> {
|
||||
),
|
||||
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,
|
||||
),
|
||||
|
@ -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<RenameFingerprintDialog> {
|
||||
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),
|
||||
|
@ -43,64 +43,76 @@ class ResetDialog extends ConsumerStatefulWidget {
|
||||
class _ResetDialogState extends ConsumerState<ResetDialog> {
|
||||
StreamSubscription<InteractionEvent>? _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<ResetDialog> {
|
||||
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),
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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<OathAddAccountPage> {
|
||||
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
|
||||
int _digits = defaultDigits;
|
||||
int _counter = defaultCounter;
|
||||
bool _validateSecretLength = false;
|
||||
bool _validateSecret = false;
|
||||
bool _dataLoaded = false;
|
||||
bool _isObscure = true;
|
||||
List<int> _periodValues = [20, 30, 45, 60];
|
||||
@ -235,6 +233,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
|
||||
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<OathAddAccountPage> {
|
||||
}
|
||||
|
||||
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<OathAddAccountPage> {
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_validateSecretLength = true;
|
||||
_validateSecret = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -365,17 +364,17 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
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<OathAddAccountPage> {
|
||||
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<OathAddAccountPage> {
|
||||
: 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<OathAddAccountPage> {
|
||||
// would hint to use saved passwords for this field
|
||||
autofillHints:
|
||||
isAndroid ? [] : const [AutofillHints.password],
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
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<OathAddAccountPage> {
|
||||
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: (_) {
|
||||
|
@ -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<ManagePasswordDialog> {
|
||||
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<ManagePasswordDialog> {
|
||||
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<ManagePasswordDialog> {
|
||||
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<ManagePasswordDialog> {
|
||||
),
|
||||
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,
|
||||
|
@ -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)),
|
||||
|
@ -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<RenameAccountDialog> {
|
||||
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<RenameAccountDialog> {
|
||||
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
|
||||
|
@ -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<UnlockForm> {
|
||||
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<UnlockForm> {
|
||||
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<UnlockForm> {
|
||||
}), // 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
30
lib/otp/features.dart
Normal file
30
lib/otp/features.dart
Normal file
@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '../app/features.dart';
|
||||
|
||||
final actions = otp.feature('actions');
|
||||
|
||||
final actionsSwap = actions.feature('swap');
|
||||
|
||||
final slots = otp.feature('slots');
|
||||
|
||||
final slotsConfigureChalResp = slots.feature('configureChalResp');
|
||||
final slotsConfigureHotp = slots.feature('configureHotp');
|
||||
final slotsConfigureStatic = slots.feature('configureSlots');
|
||||
final slotsConfigureYubiOtp = slots.feature('configureYubiOtp');
|
||||
|
||||
final slotsDelete = slots.feature('delete');
|
38
lib/otp/keys.dart
Normal file
38
lib/otp/keys.dart
Normal file
@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (C) 2022-2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _prefix = 'otp.keys';
|
||||
const _keyAction = '$_prefix.actions';
|
||||
const _slotAction = '$_prefix.slot.actions';
|
||||
|
||||
// Key actions
|
||||
const swapSlots = Key('$_keyAction.swap_slots');
|
||||
|
||||
// Slot actions
|
||||
const configureYubiOtp = Key('$_slotAction.configure_yubiotp');
|
||||
const configureHotp = Key('$_slotAction.configure_hotp');
|
||||
const configureStatic = Key('$_slotAction.configure_static');
|
||||
const configureChalResp = Key('$_slotAction.configure_chal_resp');
|
||||
const deleteAction = Key('$_slotAction.delete');
|
||||
|
||||
const saveButton = Key('$_prefix.save');
|
||||
const deleteButton = Key('$_prefix.delete');
|
||||
|
||||
const secretField = Key('$_prefix.secret');
|
||||
const publicIdField = Key('$_prefix.public_id');
|
||||
const privateIdField = Key('$_prefix.private_id');
|
113
lib/otp/models.dart
Normal file
113
lib/otp/models.dart
Normal file
@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'models.freezed.dart';
|
||||
part 'models.g.dart';
|
||||
|
||||
enum SlotId {
|
||||
one('one', 1),
|
||||
two('two', 2);
|
||||
|
||||
final String id;
|
||||
final int numberId;
|
||||
const SlotId(this.id, this.numberId);
|
||||
|
||||
String getDisplayName(AppLocalizations l10n) {
|
||||
return switch (this) {
|
||||
SlotId.one => l10n.s_otp_slot_one,
|
||||
SlotId.two => l10n.s_otp_slot_two
|
||||
};
|
||||
}
|
||||
|
||||
factory SlotId.fromJson(String value) =>
|
||||
SlotId.values.firstWhere((e) => e.id == value);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class OtpState with _$OtpState {
|
||||
const OtpState._();
|
||||
factory OtpState({
|
||||
required bool slot1Configured,
|
||||
required bool slot2Configured,
|
||||
}) = _OtpState;
|
||||
|
||||
factory OtpState.fromJson(Map<String, dynamic> json) =>
|
||||
_$OtpStateFromJson(json);
|
||||
|
||||
List<OtpSlot> get slots => [
|
||||
OtpSlot(slot: SlotId.one, isConfigured: slot1Configured),
|
||||
OtpSlot(slot: SlotId.two, isConfigured: slot2Configured),
|
||||
];
|
||||
}
|
||||
|
||||
@freezed
|
||||
class OtpSlot with _$OtpSlot {
|
||||
factory OtpSlot({required SlotId slot, required bool isConfigured}) =
|
||||
_OtpSlot;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SlotConfigurationOptions with _$SlotConfigurationOptions {
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
factory SlotConfigurationOptions(
|
||||
{bool? digits8,
|
||||
bool? requireTouch,
|
||||
bool? appendCr}) = _SlotConfigurationOptions;
|
||||
|
||||
factory SlotConfigurationOptions.fromJson(Map<String, dynamic> json) =>
|
||||
_$SlotConfigurationOptionsFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(unionKey: 'type', unionValueCase: FreezedUnionCase.snake)
|
||||
class SlotConfiguration with _$SlotConfiguration {
|
||||
const SlotConfiguration._();
|
||||
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonSerializable(explicitToJson: true, includeIfNull: false)
|
||||
const factory SlotConfiguration.hotp(
|
||||
{required String key,
|
||||
SlotConfigurationOptions? options}) = _SlotConfigurationHotp;
|
||||
|
||||
@FreezedUnionValue('hmac_sha1')
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonSerializable(explicitToJson: true, includeIfNull: false)
|
||||
const factory SlotConfiguration.chalresp(
|
||||
{required String key,
|
||||
SlotConfigurationOptions? options}) = _SlotConfigurationHmacSha1;
|
||||
|
||||
@FreezedUnionValue('static_password')
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonSerializable(explicitToJson: true, includeIfNull: false)
|
||||
const factory SlotConfiguration.static(
|
||||
{required String password,
|
||||
required String keyboardLayout,
|
||||
SlotConfigurationOptions? options}) = _SlotConfigurationStaticPassword;
|
||||
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonSerializable(explicitToJson: true, includeIfNull: false)
|
||||
const factory SlotConfiguration.yubiotp(
|
||||
{required String publicId,
|
||||
required String privateId,
|
||||
required String key,
|
||||
SlotConfigurationOptions? options}) = _SlotConfigurationYubiOtp;
|
||||
|
||||
factory SlotConfiguration.fromJson(Map<String, dynamic> json) =>
|
||||
_$SlotConfigurationFromJson(json);
|
||||
}
|
1491
lib/otp/models.freezed.dart
Normal file
1491
lib/otp/models.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
161
lib/otp/models.g.dart
Normal file
161
lib/otp/models.g.dart
Normal file
@ -0,0 +1,161 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'models.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$OtpStateImpl _$$OtpStateImplFromJson(Map<String, dynamic> json) =>
|
||||
_$OtpStateImpl(
|
||||
slot1Configured: json['slot1_configured'] as bool,
|
||||
slot2Configured: json['slot2_configured'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$OtpStateImplToJson(_$OtpStateImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'slot1_configured': instance.slot1Configured,
|
||||
'slot2_configured': instance.slot2Configured,
|
||||
};
|
||||
|
||||
_$SlotConfigurationOptionsImpl _$$SlotConfigurationOptionsImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SlotConfigurationOptionsImpl(
|
||||
digits8: json['digits8'] as bool?,
|
||||
requireTouch: json['require_touch'] as bool?,
|
||||
appendCr: json['append_cr'] as bool?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SlotConfigurationOptionsImplToJson(
|
||||
_$SlotConfigurationOptionsImpl instance) {
|
||||
final val = <String, dynamic>{};
|
||||
|
||||
void writeNotNull(String key, dynamic value) {
|
||||
if (value != null) {
|
||||
val[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeNotNull('digits8', instance.digits8);
|
||||
writeNotNull('require_touch', instance.requireTouch);
|
||||
writeNotNull('append_cr', instance.appendCr);
|
||||
return val;
|
||||
}
|
||||
|
||||
_$SlotConfigurationHotpImpl _$$SlotConfigurationHotpImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SlotConfigurationHotpImpl(
|
||||
key: json['key'] as String,
|
||||
options: json['options'] == null
|
||||
? null
|
||||
: SlotConfigurationOptions.fromJson(
|
||||
json['options'] as Map<String, dynamic>),
|
||||
$type: json['type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SlotConfigurationHotpImplToJson(
|
||||
_$SlotConfigurationHotpImpl instance) {
|
||||
final val = <String, dynamic>{
|
||||
'key': instance.key,
|
||||
};
|
||||
|
||||
void writeNotNull(String key, dynamic value) {
|
||||
if (value != null) {
|
||||
val[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeNotNull('options', instance.options?.toJson());
|
||||
val['type'] = instance.$type;
|
||||
return val;
|
||||
}
|
||||
|
||||
_$SlotConfigurationHmacSha1Impl _$$SlotConfigurationHmacSha1ImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SlotConfigurationHmacSha1Impl(
|
||||
key: json['key'] as String,
|
||||
options: json['options'] == null
|
||||
? null
|
||||
: SlotConfigurationOptions.fromJson(
|
||||
json['options'] as Map<String, dynamic>),
|
||||
$type: json['type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SlotConfigurationHmacSha1ImplToJson(
|
||||
_$SlotConfigurationHmacSha1Impl instance) {
|
||||
final val = <String, dynamic>{
|
||||
'key': instance.key,
|
||||
};
|
||||
|
||||
void writeNotNull(String key, dynamic value) {
|
||||
if (value != null) {
|
||||
val[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeNotNull('options', instance.options?.toJson());
|
||||
val['type'] = instance.$type;
|
||||
return val;
|
||||
}
|
||||
|
||||
_$SlotConfigurationStaticPasswordImpl
|
||||
_$$SlotConfigurationStaticPasswordImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SlotConfigurationStaticPasswordImpl(
|
||||
password: json['password'] as String,
|
||||
keyboardLayout: json['keyboard_layout'] as String,
|
||||
options: json['options'] == null
|
||||
? null
|
||||
: SlotConfigurationOptions.fromJson(
|
||||
json['options'] as Map<String, dynamic>),
|
||||
$type: json['type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SlotConfigurationStaticPasswordImplToJson(
|
||||
_$SlotConfigurationStaticPasswordImpl instance) {
|
||||
final val = <String, dynamic>{
|
||||
'password': instance.password,
|
||||
'keyboard_layout': instance.keyboardLayout,
|
||||
};
|
||||
|
||||
void writeNotNull(String key, dynamic value) {
|
||||
if (value != null) {
|
||||
val[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeNotNull('options', instance.options?.toJson());
|
||||
val['type'] = instance.$type;
|
||||
return val;
|
||||
}
|
||||
|
||||
_$SlotConfigurationYubiOtpImpl _$$SlotConfigurationYubiOtpImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SlotConfigurationYubiOtpImpl(
|
||||
publicId: json['public_id'] as String,
|
||||
privateId: json['private_id'] as String,
|
||||
key: json['key'] as String,
|
||||
options: json['options'] == null
|
||||
? null
|
||||
: SlotConfigurationOptions.fromJson(
|
||||
json['options'] as Map<String, dynamic>),
|
||||
$type: json['type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SlotConfigurationYubiOtpImplToJson(
|
||||
_$SlotConfigurationYubiOtpImpl instance) {
|
||||
final val = <String, dynamic>{
|
||||
'public_id': instance.publicId,
|
||||
'private_id': instance.privateId,
|
||||
'key': instance.key,
|
||||
};
|
||||
|
||||
void writeNotNull(String key, dynamic value) {
|
||||
if (value != null) {
|
||||
val[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeNotNull('options', instance.options?.toJson());
|
||||
val['type'] = instance.$type;
|
||||
return val;
|
||||
}
|
52
lib/otp/state.dart
Normal file
52
lib/otp/state.dart
Normal file
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../app/models.dart';
|
||||
import '../core/state.dart';
|
||||
import 'models.dart';
|
||||
|
||||
final yubiOtpOutputProvider =
|
||||
StateNotifierProvider<YubiOtpOutputNotifier, File?>(
|
||||
(ref) => YubiOtpOutputNotifier());
|
||||
|
||||
class YubiOtpOutputNotifier extends StateNotifier<File?> {
|
||||
YubiOtpOutputNotifier() : super(null);
|
||||
|
||||
void setOutput(File? file) {
|
||||
state = file;
|
||||
}
|
||||
}
|
||||
|
||||
final otpStateProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<OtpStateNotifier, OtpState, DevicePath>(
|
||||
() => throw UnimplementedError(),
|
||||
);
|
||||
|
||||
abstract class OtpStateNotifier extends ApplicationStateNotifier<OtpState> {
|
||||
Future<String> generateStaticPassword(int length, String layout);
|
||||
Future<String> modhexEncodeSerial(int serial);
|
||||
Future<Map<String, List<String>>> getKeyboardLayouts();
|
||||
Future<String> formatYubiOtpCsv(
|
||||
int serial, String publicId, String privateId, String key);
|
||||
Future<void> swapSlots();
|
||||
Future<void> configureSlot(SlotId slot,
|
||||
{required SlotConfiguration configuration});
|
||||
Future<void> deleteSlot(SlotId slot);
|
||||
}
|
175
lib/otp/views/actions.dart
Normal file
175
lib/otp/views/actions.dart
Normal file
@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/shortcuts.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'configure_chalresp_dialog.dart';
|
||||
import 'configure_hotp_dialog.dart';
|
||||
import 'configure_static_dialog.dart';
|
||||
import 'configure_yubiotp_dialog.dart';
|
||||
import 'delete_slot_dialog.dart';
|
||||
|
||||
class ConfigureChalRespIntent extends Intent {
|
||||
const ConfigureChalRespIntent();
|
||||
}
|
||||
|
||||
class ConfigureHotpIntent extends Intent {
|
||||
const ConfigureHotpIntent();
|
||||
}
|
||||
|
||||
class ConfigureStaticIntent extends Intent {
|
||||
const ConfigureStaticIntent();
|
||||
}
|
||||
|
||||
class ConfigureYubiOtpIntent extends Intent {
|
||||
const ConfigureYubiOtpIntent();
|
||||
}
|
||||
|
||||
Widget registerOtpActions(
|
||||
DevicePath devicePath,
|
||||
OtpSlot otpSlot, {
|
||||
required WidgetRef ref,
|
||||
required Widget Function(BuildContext context) builder,
|
||||
Map<Type, Action<Intent>> actions = const {},
|
||||
}) {
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
return Actions(
|
||||
actions: {
|
||||
if (hasFeature(features.slotsConfigureChalResp))
|
||||
ConfigureChalRespIntent:
|
||||
CallbackAction<ConfigureChalRespIntent>(onInvoke: (intent) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ConfigureChalrespDialog(devicePath, otpSlot));
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
if (hasFeature(features.slotsConfigureHotp))
|
||||
ConfigureHotpIntent:
|
||||
CallbackAction<ConfigureHotpIntent>(onInvoke: (intent) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfigureHotpDialog(devicePath, otpSlot));
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
if (hasFeature(features.slotsConfigureStatic))
|
||||
ConfigureStaticIntent:
|
||||
CallbackAction<ConfigureStaticIntent>(onInvoke: (intent) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
|
||||
final keyboardLayouts = await ref
|
||||
.read(otpStateProvider(devicePath).notifier)
|
||||
.getKeyboardLayouts();
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfigureStaticDialog(
|
||||
devicePath, otpSlot, keyboardLayouts));
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
if (hasFeature(features.slotsConfigureYubiOtp))
|
||||
ConfigureYubiOtpIntent:
|
||||
CallbackAction<ConfigureYubiOtpIntent>(onInvoke: (intent) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ConfigureYubiOtpDialog(devicePath, otpSlot));
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
if (hasFeature(features.slotsDelete))
|
||||
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||
final withContext = ref.read(withContextProvider);
|
||||
|
||||
final bool? deleted = await withContext((context) async =>
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
DeleteSlotDialog(devicePath, otpSlot)) ??
|
||||
false);
|
||||
return deleted;
|
||||
}),
|
||||
...actions,
|
||||
},
|
||||
child: Builder(builder: builder),
|
||||
);
|
||||
}
|
||||
|
||||
List<ActionItem> buildSlotActions(bool isConfigured, AppLocalizations l10n) {
|
||||
return [
|
||||
ActionItem(
|
||||
key: keys.configureYubiOtp,
|
||||
feature: features.slotsConfigureYubiOtp,
|
||||
icon: const Icon(Icons.shuffle_outlined),
|
||||
title: l10n.s_yubiotp,
|
||||
subtitle: l10n.l_yubiotp_desc,
|
||||
intent: const ConfigureYubiOtpIntent(),
|
||||
),
|
||||
ActionItem(
|
||||
key: keys.configureChalResp,
|
||||
feature: features.slotsConfigureChalResp,
|
||||
icon: const Icon(Icons.key_outlined),
|
||||
title: l10n.s_challenge_response,
|
||||
subtitle: l10n.l_challenge_response_desc,
|
||||
intent: const ConfigureChalRespIntent()),
|
||||
ActionItem(
|
||||
key: keys.configureStatic,
|
||||
feature: features.slotsConfigureStatic,
|
||||
icon: const Icon(Icons.password_outlined),
|
||||
title: l10n.s_static_password,
|
||||
subtitle: l10n.l_static_password_desc,
|
||||
intent: const ConfigureStaticIntent()),
|
||||
ActionItem(
|
||||
key: keys.configureHotp,
|
||||
feature: features.slotsConfigureHotp,
|
||||
icon: const Icon(Icons.tag_outlined),
|
||||
title: l10n.s_hotp,
|
||||
subtitle: l10n.l_hotp_desc,
|
||||
intent: const ConfigureHotpIntent()),
|
||||
ActionItem(
|
||||
key: keys.deleteAction,
|
||||
feature: features.slotsDelete,
|
||||
actionStyle: ActionStyle.error,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
title: l10n.s_delete_slot,
|
||||
subtitle: l10n.l_delete_slot_desc,
|
||||
intent: isConfigured ? const DeleteIntent() : null,
|
||||
)
|
||||
];
|
||||
}
|
187
lib/otp/views/configure_chalresp_dialog.dart
Normal file
187
lib/otp/views/configure_chalresp_dialog.dart
Normal file
@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../widgets/app_input_decoration.dart';
|
||||
import '../../widgets/app_text_field.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'overwrite_confirm_dialog.dart';
|
||||
|
||||
final _log = Logger('otp.view.configure_chalresp_dialog');
|
||||
|
||||
class ConfigureChalrespDialog extends ConsumerStatefulWidget {
|
||||
final DevicePath devicePath;
|
||||
final OtpSlot otpSlot;
|
||||
const ConfigureChalrespDialog(this.devicePath, this.otpSlot, {super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_ConfigureChalrespDialogState();
|
||||
}
|
||||
|
||||
class _ConfigureChalrespDialogState
|
||||
extends ConsumerState<ConfigureChalrespDialog> {
|
||||
final _secretController = TextEditingController();
|
||||
bool _validateSecret = false;
|
||||
bool _requireTouch = false;
|
||||
final int secretMaxLength = 40;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_secretController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final secret = _secretController.text;
|
||||
final secretLengthValid = secret.isNotEmpty &&
|
||||
secret.length % 2 == 0 &&
|
||||
secret.length <= secretMaxLength;
|
||||
final secretFormatValid = Format.hex.isValid(secret);
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_challenge_response),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: keys.saveButton,
|
||||
onPressed: !_validateSecret
|
||||
? () async {
|
||||
if (!secretLengthValid || !secretFormatValid) {
|
||||
setState(() {
|
||||
_validateSecret = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await confirmOverwrite(context, widget.otpSlot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final otpNotifier =
|
||||
ref.read(otpStateProvider(widget.devicePath).notifier);
|
||||
try {
|
||||
await otpNotifier.configureSlot(widget.otpSlot.slot,
|
||||
configuration: SlotConfiguration.chalresp(
|
||||
key: secret,
|
||||
options: SlotConfigurationOptions(
|
||||
requireTouch: _requireTouch)));
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
Navigator.of(context).pop();
|
||||
showMessage(
|
||||
context,
|
||||
l10n.l_slot_credential_configured(
|
||||
l10n.s_challenge_response));
|
||||
});
|
||||
} catch (e) {
|
||||
_log.error('Failed to program credential', e);
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
showMessage(
|
||||
context,
|
||||
l10n.p_otp_slot_configuration_error(
|
||||
widget.otpSlot.slot.getDisplayName(l10n)),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Text(l10n.s_save),
|
||||
)
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppTextField(
|
||||
key: keys.secretField,
|
||||
autofocus: true,
|
||||
controller: _secretController,
|
||||
autofillHints: isAndroid ? [] : const [AutofillHints.password],
|
||||
maxLength: secretMaxLength,
|
||||
decoration: AppInputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_secret_key,
|
||||
errorText: _validateSecret && !secretLengthValid
|
||||
? l10n.s_invalid_length
|
||||
: _validateSecret && !secretFormatValid
|
||||
? l10n.l_invalid_format_allowed_chars(
|
||||
Format.hex.allowedCharacters)
|
||||
: null,
|
||||
prefixIcon: const Icon(Icons.key_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
final random = Random.secure();
|
||||
final key = List.generate(
|
||||
20,
|
||||
(_) => random
|
||||
.nextInt(256)
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')).join();
|
||||
setState(() {
|
||||
_secretController.text = key;
|
||||
});
|
||||
});
|
||||
},
|
||||
tooltip: l10n.s_generate_random,
|
||||
)),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validateSecret = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
FilterChip(
|
||||
label: Text(l10n.s_require_touch),
|
||||
selected: _requireTouch,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_requireTouch = value;
|
||||
});
|
||||
},
|
||||
)
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
200
lib/otp/views/configure_hotp_dialog.dart
Normal file
200
lib/otp/views/configure_hotp_dialog.dart
Normal file
@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../oath/models.dart';
|
||||
import '../../widgets/app_input_decoration.dart';
|
||||
import '../../widgets/app_text_field.dart';
|
||||
import '../../widgets/choice_filter_chip.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'overwrite_confirm_dialog.dart';
|
||||
|
||||
final _log = Logger('otp.view.configure_hotp_dialog');
|
||||
|
||||
class ConfigureHotpDialog extends ConsumerStatefulWidget {
|
||||
final DevicePath devicePath;
|
||||
final OtpSlot otpSlot;
|
||||
const ConfigureHotpDialog(this.devicePath, this.otpSlot, {super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_ConfigureHotpDialogState();
|
||||
}
|
||||
|
||||
class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
|
||||
final _secretController = TextEditingController();
|
||||
bool _validateSecret = false;
|
||||
int _digits = defaultDigits;
|
||||
final List<int> _digitsValues = [6, 8];
|
||||
bool _appendEnter = true;
|
||||
bool _isObscure = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_secretController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final secret = _secretController.text.replaceAll(' ', '');
|
||||
final secretLengthValid = secret.isNotEmpty && secret.length * 5 % 8 < 5;
|
||||
final secretFormatValid = Format.base32.isValid(secret);
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_hotp),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: keys.saveButton,
|
||||
onPressed: !_validateSecret
|
||||
? () async {
|
||||
if (!secretLengthValid || !secretFormatValid) {
|
||||
setState(() {
|
||||
_validateSecret = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await confirmOverwrite(context, widget.otpSlot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final otpNotifier =
|
||||
ref.read(otpStateProvider(widget.devicePath).notifier);
|
||||
try {
|
||||
await otpNotifier.configureSlot(widget.otpSlot.slot,
|
||||
configuration: SlotConfiguration.hotp(
|
||||
key: secret,
|
||||
options: SlotConfigurationOptions(
|
||||
digits8: _digits == 8,
|
||||
appendCr: _appendEnter)));
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
Navigator.of(context).pop();
|
||||
showMessage(context,
|
||||
l10n.l_slot_credential_configured(l10n.s_hotp));
|
||||
});
|
||||
} catch (e) {
|
||||
_log.error('Failed to program credential', e);
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
showMessage(
|
||||
context,
|
||||
l10n.p_otp_slot_configuration_error(
|
||||
widget.otpSlot.slot.getDisplayName(l10n)),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Text(l10n.s_save),
|
||||
)
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppTextField(
|
||||
key: keys.secretField,
|
||||
controller: _secretController,
|
||||
obscureText: _isObscure,
|
||||
autofillHints: isAndroid ? [] : const [AutofillHints.password],
|
||||
decoration: AppInputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_secret_key,
|
||||
helperText: '', // Prevents resizing when errorText shown
|
||||
errorText: _validateSecret && !secretLengthValid
|
||||
? l10n.s_invalid_length
|
||||
: _validateSecret && !secretFormatValid
|
||||
? l10n.l_invalid_format_allowed_chars(
|
||||
Format.base32.allowedCharacters)
|
||||
: null,
|
||||
prefixIcon: const Icon(Icons.key_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isObscure ? Icons.visibility : Icons.visibility_off,
|
||||
color: !_validateSecret
|
||||
? IconTheme.of(context).color
|
||||
: null),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isObscure = !_isObscure;
|
||||
});
|
||||
},
|
||||
tooltip: _isObscure
|
||||
? l10n.s_show_secret_key
|
||||
: l10n.s_hide_secret_key,
|
||||
)),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validateSecret = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: Text(l10n.s_append_enter),
|
||||
tooltip: l10n.l_append_enter_desc,
|
||||
selected: _appendEnter,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_appendEnter = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
ChoiceFilterChip<int>(
|
||||
items: _digitsValues,
|
||||
value: _digits,
|
||||
selected: _digits != defaultDigits,
|
||||
itemBuilder: (value) => Text(l10n.s_num_digits(value)),
|
||||
onChanged: (digits) {
|
||||
setState(() {
|
||||
_digits = digits;
|
||||
});
|
||||
}),
|
||||
],
|
||||
)
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
222
lib/otp/views/configure_static_dialog.dart
Normal file
222
lib/otp/views/configure_static_dialog.dart
Normal file
@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../widgets/app_input_decoration.dart';
|
||||
import '../../widgets/app_text_field.dart';
|
||||
import '../../widgets/choice_filter_chip.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'overwrite_confirm_dialog.dart';
|
||||
|
||||
final _log = Logger('otp.view.configure_static_dialog');
|
||||
|
||||
class ConfigureStaticDialog extends ConsumerStatefulWidget {
|
||||
final DevicePath devicePath;
|
||||
final OtpSlot otpSlot;
|
||||
final Map<String, List<String>> keyboardLayouts;
|
||||
const ConfigureStaticDialog(
|
||||
this.devicePath, this.otpSlot, this.keyboardLayouts,
|
||||
{super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_ConfigureStaticDialogState();
|
||||
}
|
||||
|
||||
class _ConfigureStaticDialogState extends ConsumerState<ConfigureStaticDialog> {
|
||||
final _passwordController = TextEditingController();
|
||||
final passwordMaxLength = 38;
|
||||
bool _validatePassword = false;
|
||||
bool _appendEnter = true;
|
||||
String _keyboardLayout = '';
|
||||
String _defaultKeyboardLayout = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final modhexLayout = widget.keyboardLayouts.keys.toList()[0];
|
||||
_keyboardLayout = modhexLayout;
|
||||
_defaultKeyboardLayout = modhexLayout;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
RegExp generateFormatterPattern(String layout) {
|
||||
final allowedCharacters = widget.keyboardLayouts[layout] ?? [];
|
||||
|
||||
final pattern =
|
||||
allowedCharacters.map((char) => RegExp.escape(char)).join('');
|
||||
|
||||
return RegExp('^[$pattern]+\$', caseSensitive: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final password = _passwordController.text.replaceAll(' ', '');
|
||||
final passwordLengthValid =
|
||||
password.isNotEmpty && password.length <= passwordMaxLength;
|
||||
final passwordFormatValid =
|
||||
generateFormatterPattern(_keyboardLayout).hasMatch(password);
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_static_password),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: keys.saveButton,
|
||||
onPressed: !_validatePassword
|
||||
? () async {
|
||||
if (!passwordLengthValid || !passwordFormatValid) {
|
||||
setState(() {
|
||||
_validatePassword = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await confirmOverwrite(context, widget.otpSlot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final otpNotifier =
|
||||
ref.read(otpStateProvider(widget.devicePath).notifier);
|
||||
try {
|
||||
await otpNotifier.configureSlot(widget.otpSlot.slot,
|
||||
configuration: SlotConfiguration.static(
|
||||
password: password,
|
||||
keyboardLayout: _keyboardLayout,
|
||||
options: SlotConfigurationOptions(
|
||||
appendCr: _appendEnter)));
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
Navigator.of(context).pop();
|
||||
showMessage(
|
||||
context,
|
||||
l10n.l_slot_credential_configured(
|
||||
l10n.s_static_password));
|
||||
});
|
||||
} catch (e) {
|
||||
_log.error('Failed to program credential', e);
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
showMessage(
|
||||
context,
|
||||
l10n.p_otp_slot_configuration_error(
|
||||
widget.otpSlot.slot.getDisplayName(l10n)),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Text(l10n.s_save),
|
||||
)
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppTextField(
|
||||
key: keys.secretField,
|
||||
autofocus: true,
|
||||
controller: _passwordController,
|
||||
autofillHints: isAndroid ? [] : const [AutofillHints.password],
|
||||
maxLength: passwordMaxLength,
|
||||
decoration: AppInputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_password,
|
||||
errorText: _validatePassword && !passwordLengthValid
|
||||
? l10n.s_invalid_length
|
||||
: _validatePassword && !passwordFormatValid
|
||||
? l10n.l_invalid_keyboard_character
|
||||
: null,
|
||||
prefixIcon: const Icon(Icons.key_outlined),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: l10n.s_generate_passowrd,
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () async {
|
||||
final password = await ref
|
||||
.read(otpStateProvider(widget.devicePath).notifier)
|
||||
.generateStaticPassword(
|
||||
passwordMaxLength, _keyboardLayout);
|
||||
setState(() {
|
||||
_validatePassword = false;
|
||||
_passwordController.text = password;
|
||||
});
|
||||
},
|
||||
)),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validatePassword = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: Text(l10n.s_append_enter),
|
||||
tooltip: l10n.l_append_enter_desc,
|
||||
selected: _appendEnter,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_appendEnter = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
ChoiceFilterChip(
|
||||
items: widget.keyboardLayouts.keys.toList(),
|
||||
value: _keyboardLayout,
|
||||
selected: _keyboardLayout != _defaultKeyboardLayout,
|
||||
labelBuilder: (value) => Text('Keyboard $value'),
|
||||
itemBuilder: (value) => Text(value),
|
||||
onChanged: (layout) {
|
||||
setState(() {
|
||||
_keyboardLayout = layout;
|
||||
_validatePassword = false;
|
||||
});
|
||||
}),
|
||||
],
|
||||
)
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
375
lib/otp/views/configure_yubiotp_dialog.dart
Normal file
375
lib/otp/views/configure_yubiotp_dialog.dart
Normal file
@ -0,0 +1,375 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../widgets/app_input_decoration.dart';
|
||||
import '../../widgets/app_text_field.dart';
|
||||
import '../../widgets/choice_filter_chip.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'overwrite_confirm_dialog.dart';
|
||||
|
||||
final _log = Logger('otp.view.configure_yubiotp_dialog');
|
||||
|
||||
enum OutputActions {
|
||||
selectFile,
|
||||
noOutput;
|
||||
|
||||
const OutputActions();
|
||||
|
||||
String getDisplayName(AppLocalizations l10n) => switch (this) {
|
||||
OutputActions.selectFile => l10n.l_select_file,
|
||||
OutputActions.noOutput => l10n.l_no_export_file
|
||||
};
|
||||
}
|
||||
|
||||
class ConfigureYubiOtpDialog extends ConsumerStatefulWidget {
|
||||
final DevicePath devicePath;
|
||||
final OtpSlot otpSlot;
|
||||
const ConfigureYubiOtpDialog(this.devicePath, this.otpSlot, {super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_ConfigureYubiOtpDialogState();
|
||||
}
|
||||
|
||||
class _ConfigureYubiOtpDialogState
|
||||
extends ConsumerState<ConfigureYubiOtpDialog> {
|
||||
final _secretController = TextEditingController();
|
||||
final _publicIdController = TextEditingController();
|
||||
final _privateIdController = TextEditingController();
|
||||
OutputActions _action = OutputActions.noOutput;
|
||||
bool _appendEnter = true;
|
||||
bool _validateSecretFormat = false;
|
||||
bool _validatePublicIdFormat = false;
|
||||
bool _validatePrivateIdFormat = false;
|
||||
final secretLength = 32;
|
||||
final publicIdLength = 12;
|
||||
final privateIdLength = 12;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_secretController.dispose();
|
||||
_publicIdController.dispose();
|
||||
_privateIdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final info = ref.watch(currentDeviceDataProvider).valueOrNull?.info;
|
||||
|
||||
final secret = _secretController.text;
|
||||
final secretLengthValid = secret.length == secretLength;
|
||||
final secretFormatValid = Format.hex.isValid(secret);
|
||||
|
||||
final privateId = _privateIdController.text;
|
||||
final privateIdLengthValid = privateId.length == privateIdLength;
|
||||
final privatedIdFormatValid = Format.hex.isValid(privateId);
|
||||
|
||||
final publicId = _publicIdController.text;
|
||||
final publicIdLengthValid = publicId.length == publicIdLength;
|
||||
final publicIdFormatValid = Format.modhex.isValid(publicId);
|
||||
|
||||
final lengthsValid =
|
||||
secretLengthValid && privateIdLengthValid && publicIdLengthValid;
|
||||
|
||||
final outputFile = ref.read(yubiOtpOutputProvider);
|
||||
|
||||
Future<bool> selectFile() async {
|
||||
final filePath = await FilePicker.platform.saveFile(
|
||||
dialogTitle: l10n.l_export_configuration_file,
|
||||
allowedExtensions: ['csv'],
|
||||
type: FileType.custom,
|
||||
lockParentWindow: true);
|
||||
|
||||
if (filePath == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ref.read(yubiOtpOutputProvider.notifier).setOutput(File(filePath));
|
||||
return true;
|
||||
}
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_yubiotp),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: keys.saveButton,
|
||||
onPressed: lengthsValid
|
||||
? () async {
|
||||
if (!secretFormatValid ||
|
||||
!publicIdFormatValid ||
|
||||
!privatedIdFormatValid) {
|
||||
setState(() {
|
||||
_validateSecretFormat = !secretFormatValid;
|
||||
_validatePublicIdFormat = !publicIdFormatValid;
|
||||
_validatePrivateIdFormat = !privatedIdFormatValid;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await confirmOverwrite(context, widget.otpSlot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final otpNotifier =
|
||||
ref.read(otpStateProvider(widget.devicePath).notifier);
|
||||
try {
|
||||
await otpNotifier.configureSlot(widget.otpSlot.slot,
|
||||
configuration: SlotConfiguration.yubiotp(
|
||||
publicId: publicId,
|
||||
privateId: privateId,
|
||||
key: secret,
|
||||
options: SlotConfigurationOptions(
|
||||
appendCr: _appendEnter)));
|
||||
if (outputFile != null) {
|
||||
final csv = await otpNotifier.formatYubiOtpCsv(
|
||||
info!.serial!, publicId, privateId, secret);
|
||||
|
||||
await outputFile.writeAsString(
|
||||
'$csv${Platform.lineTerminator}',
|
||||
mode: FileMode.append);
|
||||
}
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
Navigator.of(context).pop();
|
||||
showMessage(
|
||||
context,
|
||||
outputFile != null
|
||||
? l10n.l_slot_credential_configured_and_exported(
|
||||
l10n.s_yubiotp,
|
||||
outputFile.uri.pathSegments.last)
|
||||
: l10n.l_slot_credential_configured(
|
||||
l10n.s_yubiotp));
|
||||
});
|
||||
} catch (e) {
|
||||
_log.error('Failed to program credential', e);
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
final String errorMessage;
|
||||
if (e is PathNotFoundException) {
|
||||
errorMessage = '${e.message} ${e.path.toString()}';
|
||||
} else {
|
||||
errorMessage = l10n.p_otp_slot_configuration_error(
|
||||
widget.otpSlot.slot.getDisplayName(l10n));
|
||||
}
|
||||
showMessage(
|
||||
context,
|
||||
errorMessage,
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Text(l10n.s_save),
|
||||
)
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppTextField(
|
||||
key: keys.publicIdField,
|
||||
autofocus: true,
|
||||
controller: _publicIdController,
|
||||
autofillHints: isAndroid ? [] : const [AutofillHints.password],
|
||||
maxLength: publicIdLength,
|
||||
decoration: AppInputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_public_id,
|
||||
errorText: _validatePublicIdFormat && !publicIdFormatValid
|
||||
? l10n.l_invalid_format_allowed_chars(
|
||||
Format.modhex.allowedCharacters)
|
||||
: null,
|
||||
prefixIcon: const Icon(Icons.public_outlined),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: l10n.s_use_serial,
|
||||
icon: const Icon(Icons.auto_awesome_outlined),
|
||||
onPressed: (info?.serial != null)
|
||||
? () async {
|
||||
final publicId = await ref
|
||||
.read(otpStateProvider(widget.devicePath)
|
||||
.notifier)
|
||||
.modhexEncodeSerial(info!.serial!);
|
||||
setState(() {
|
||||
_publicIdController.text = publicId;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
)),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validatePublicIdFormat = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
AppTextField(
|
||||
key: keys.privateIdField,
|
||||
controller: _privateIdController,
|
||||
autofillHints: isAndroid ? [] : const [AutofillHints.password],
|
||||
maxLength: privateIdLength,
|
||||
decoration: AppInputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_private_id,
|
||||
errorText: _validatePrivateIdFormat && !privatedIdFormatValid
|
||||
? l10n.l_invalid_format_allowed_chars(
|
||||
Format.hex.allowedCharacters)
|
||||
: null,
|
||||
prefixIcon: const Icon(Icons.key_outlined),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: l10n.s_generate_random,
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
final random = Random.secure();
|
||||
final key = List.generate(
|
||||
6,
|
||||
(_) => random
|
||||
.nextInt(256)
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')).join();
|
||||
setState(() {
|
||||
_privateIdController.text = key;
|
||||
});
|
||||
},
|
||||
)),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validatePrivateIdFormat = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
AppTextField(
|
||||
key: keys.secretField,
|
||||
controller: _secretController,
|
||||
autofillHints: isAndroid ? [] : const [AutofillHints.password],
|
||||
maxLength: secretLength,
|
||||
decoration: AppInputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_secret_key,
|
||||
errorText: _validateSecretFormat && !secretFormatValid
|
||||
? l10n.l_invalid_format_allowed_chars(
|
||||
Format.hex.allowedCharacters)
|
||||
: null,
|
||||
prefixIcon: const Icon(Icons.key_outlined),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: l10n.s_generate_random,
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
final random = Random.secure();
|
||||
final key = List.generate(
|
||||
16,
|
||||
(_) => random
|
||||
.nextInt(256)
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')).join();
|
||||
setState(() {
|
||||
_secretController.text = key;
|
||||
});
|
||||
},
|
||||
)),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validateSecretFormat = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: Text(l10n.s_append_enter),
|
||||
tooltip: l10n.l_append_enter_desc,
|
||||
selected: _appendEnter,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_appendEnter = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
ChoiceFilterChip<OutputActions>(
|
||||
tooltip: outputFile?.path ?? l10n.s_no_export,
|
||||
selected: outputFile != null,
|
||||
avatar: outputFile != null
|
||||
? Icon(Icons.check,
|
||||
color: Theme.of(context).colorScheme.secondary)
|
||||
: null,
|
||||
value: _action,
|
||||
items: OutputActions.values,
|
||||
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
|
||||
labelBuilder: (_) {
|
||||
String? fileName = outputFile?.uri.pathSegments.last;
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 140),
|
||||
child: Text(
|
||||
fileName != null
|
||||
? '${l10n.s_export} $fileName'
|
||||
: _action.getDisplayName(l10n),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
},
|
||||
onChanged: (value) async {
|
||||
if (value == OutputActions.noOutput) {
|
||||
ref.read(yubiOtpOutputProvider.notifier).setOutput(null);
|
||||
setState(() {
|
||||
_action = value;
|
||||
});
|
||||
} else if (value == OutputActions.selectFile) {
|
||||
if (await selectFile()) {
|
||||
setState(() {
|
||||
_action = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
83
lib/otp/views/delete_slot_dialog.dart
Normal file
83
lib/otp/views/delete_slot_dialog.dart
Normal file
@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
|
||||
class DeleteSlotDialog extends ConsumerWidget {
|
||||
final DevicePath devicePath;
|
||||
final OtpSlot otpSlot;
|
||||
const DeleteSlotDialog(this.devicePath, this.otpSlot, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_delete_slot),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: keys.deleteButton,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref
|
||||
.read(otpStateProvider(devicePath).notifier)
|
||||
.deleteSlot(otpSlot.slot);
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
Navigator.of(context).pop(true);
|
||||
showMessage(context, l10n.l_slot_deleted);
|
||||
});
|
||||
} catch (e) {
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
Navigator.of(context).pop(true);
|
||||
showMessage(
|
||||
context,
|
||||
l10n.p_otp_slot_configuration_error(
|
||||
otpSlot.slot.getDisplayName(l10n)),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(l10n.s_delete),
|
||||
)
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n
|
||||
.p_warning_delete_slot_configuration(otpSlot.slot.numberId)),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
57
lib/otp/views/key_actions.dart
Normal file
57
lib/otp/views/key_actions.dart
Normal file
@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/views/action_list.dart';
|
||||
import '../../app/views/fs_dialog.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import 'swap_slots_dialog.dart';
|
||||
|
||||
Widget otpBuildActions(BuildContext context, DevicePath devicePath,
|
||||
OtpState otpState, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
ActionListSection(l10n.s_manage, children: [
|
||||
ActionListItem(
|
||||
key: keys.swapSlots,
|
||||
feature: features.actionsSwap,
|
||||
title: l10n.s_swap_slots,
|
||||
subtitle: l10n.l_swap_slots_desc,
|
||||
icon: const Icon(Icons.swap_vert_outlined),
|
||||
onTap: (otpState.slot1Configured || otpState.slot2Configured)
|
||||
? (context) {
|
||||
Navigator.of(context).pop();
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => SwapSlotsDialog(devicePath));
|
||||
}
|
||||
: null,
|
||||
)
|
||||
])
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
113
lib/otp/views/otp_screen.dart
Normal file
113
lib/otp/views/otp_screen.dart
Normal file
@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/shortcuts.dart';
|
||||
import '../../app/views/app_failure_page.dart';
|
||||
import '../../app/views/app_list_item.dart';
|
||||
import '../../app/views/app_page.dart';
|
||||
import '../../app/views/message_page.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../widgets/list_title.dart';
|
||||
import '../features.dart' as features;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'actions.dart';
|
||||
import 'key_actions.dart';
|
||||
import 'slot_dialog.dart';
|
||||
|
||||
class OtpScreen extends ConsumerWidget {
|
||||
final DevicePath devicePath;
|
||||
|
||||
const OtpScreen(this.devicePath, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
return ref.watch(otpStateProvider(devicePath)).when(
|
||||
loading: () => MessagePage(
|
||||
title: Text(l10n.s_slots),
|
||||
graphic: const CircularProgressIndicator(),
|
||||
delayedContent: true,
|
||||
),
|
||||
error: (error, _) =>
|
||||
AppFailurePage(title: Text(l10n.s_slots), cause: error),
|
||||
data: (otpState) {
|
||||
return AppPage(
|
||||
title: Text(l10n.s_slots),
|
||||
keyActionsBuilder: hasFeature(features.actions)
|
||||
? (context) =>
|
||||
otpBuildActions(context, devicePath, otpState, ref)
|
||||
: null,
|
||||
child: Column(children: [
|
||||
ListTitle(l10n.s_slots),
|
||||
...otpState.slots.map((e) => registerOtpActions(devicePath, e,
|
||||
ref: ref,
|
||||
actions: {
|
||||
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (context) => SlotDialog(e.slot),
|
||||
);
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
builder: (context) => _SlotListItem(e)))
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotListItem extends ConsumerWidget {
|
||||
final OtpSlot otpSlot;
|
||||
const _SlotListItem(this.otpSlot);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final slot = otpSlot.slot;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isConfigured = otpSlot.isConfigured;
|
||||
final hasFeature = ref.watch(featureProvider);
|
||||
|
||||
return Semantics(
|
||||
label: slot.getDisplayName(l10n),
|
||||
child: AppListItem(
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: colorScheme.onSecondary,
|
||||
backgroundColor: colorScheme.secondary,
|
||||
child: Text(slot.numberId.toString())),
|
||||
title: slot.getDisplayName(l10n),
|
||||
subtitle:
|
||||
isConfigured ? l10n.l_otp_slot_configured : l10n.l_otp_slot_empty,
|
||||
trailing: OutlinedButton(
|
||||
onPressed: Actions.handler(context, const OpenIntent()),
|
||||
child: const Icon(Icons.more_horiz),
|
||||
),
|
||||
buildPopupActions: hasFeature(features.slots)
|
||||
? (context) => buildSlotActions(isConfigured, l10n)
|
||||
: null,
|
||||
));
|
||||
}
|
||||
}
|
66
lib/otp/views/overwrite_confirm_dialog.dart
Normal file
66
lib/otp/views/overwrite_confirm_dialog.dart
Normal file
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../models.dart';
|
||||
|
||||
class _OverwriteConfirmDialog extends StatelessWidget {
|
||||
final OtpSlot otpSlot;
|
||||
|
||||
const _OverwriteConfirmDialog({
|
||||
required this.otpSlot,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_overwrite_slot),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(l10n.s_overwrite)),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.p_overwrite_slot_desc(otpSlot.slot.getDisplayName(l10n))),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> confirmOverwrite(BuildContext context, OtpSlot otpSlot) async {
|
||||
if (otpSlot.isConfigured) {
|
||||
return await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => _OverwriteConfirmDialog(
|
||||
otpSlot: otpSlot,
|
||||
)) ??
|
||||
false;
|
||||
}
|
||||
return true;
|
||||
}
|
93
lib/otp/views/slot_dialog.dart
Normal file
93
lib/otp/views/slot_dialog.dart
Normal file
@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/state.dart';
|
||||
import '../../app/views/action_list.dart';
|
||||
import '../../app/views/fs_dialog.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'actions.dart';
|
||||
|
||||
class SlotDialog extends ConsumerWidget {
|
||||
final SlotId slot;
|
||||
const SlotDialog(this.slot, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// TODO: Solve this in a cleaner way
|
||||
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
|
||||
if (node == null) {
|
||||
// The rest of this method assumes there is a device, and will throw an exception if not.
|
||||
// This will never be shown, as the dialog will be immediately closed
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final otpState = ref.watch(otpStateProvider(node.path)).valueOrNull;
|
||||
final otpSlot =
|
||||
otpState!.slots.firstWhereOrNull((element) => element.slot == slot);
|
||||
|
||||
if (otpSlot == null) {
|
||||
return const FsDialog(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return registerOtpActions(node.path, otpSlot,
|
||||
ref: ref,
|
||||
builder: (context) => FocusScope(
|
||||
autofocus: true,
|
||||
child: FsDialog(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48, bottom: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
otpSlot.slot.getDisplayName(l10n),
|
||||
style: textTheme.headlineSmall,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Icon(
|
||||
Icons.touch_app,
|
||||
size: 100.0,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(otpSlot.isConfigured
|
||||
? l10n.l_otp_slot_configured
|
||||
: l10n.l_otp_slot_empty)
|
||||
],
|
||||
),
|
||||
),
|
||||
ActionListSection.fromMenuActions(
|
||||
context,
|
||||
l10n.s_setup,
|
||||
actions: buildSlotActions(otpSlot.isConfigured, l10n),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
63
lib/otp/views/swap_slots_dialog.dart
Normal file
63
lib/otp/views/swap_slots_dialog.dart
Normal file
@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../state.dart';
|
||||
|
||||
class SwapSlotsDialog extends ConsumerWidget {
|
||||
final DevicePath devicePath;
|
||||
const SwapSlotsDialog(this.devicePath, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_swap_slots),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(otpStateProvider(devicePath).notifier).swapSlots();
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
Navigator.of(context).pop();
|
||||
showMessage(context, l10n.l_slots_swapped);
|
||||
});
|
||||
},
|
||||
child: Text(l10n.s_swap))
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.p_swap_slots_desc),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<AuthenticationDialog> {
|
||||
bool _defaultKeyUsed = false;
|
||||
bool _keyIsWrong = false;
|
||||
bool _keyFormatInvalid = false;
|
||||
final _keyController = TextEditingController();
|
||||
|
||||
@override
|
||||
@ -56,6 +58,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
|
||||
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<AuthenticationDialog> {
|
||||
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<AuthenticationDialog> {
|
||||
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<AuthenticationDialog> {
|
||||
tooltip: l10n.s_use_default,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_keyFormatInvalid = false;
|
||||
_defaultKeyUsed = !_defaultKeyUsed;
|
||||
if (_defaultKeyUsed) {
|
||||
_keyController.text = defaultManagementKey;
|
||||
@ -135,6 +146,7 @@ class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_keyIsWrong = false;
|
||||
_keyFormatInvalid = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
@ -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<GenerateKeyDialog> {
|
||||
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) {
|
||||
|
@ -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<ImportFileDialog> {
|
||||
String _password = '';
|
||||
bool _passwordIsWrong = false;
|
||||
bool _importing = false;
|
||||
bool _isObscure = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -125,15 +127,27 @@ class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
|
||||
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(() {
|
||||
|
@ -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<ManageKeyDialog> {
|
||||
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<ManageKeyDialog> {
|
||||
}
|
||||
|
||||
_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<ManageKeyDialog> {
|
||||
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<ManageKeyDialog> {
|
||||
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<ManageKeyDialog> {
|
||||
},
|
||||
),
|
||||
),
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp('[a-f0-9]', caseSensitive: false))
|
||||
],
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@ -227,15 +255,15 @@ class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
|
||||
autofillHints: const [AutofillHints.newPassword],
|
||||
maxLength: hexLength,
|
||||
controller: _keyController,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
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<ManageKeyDialog> {
|
||||
.padLeft(2, '0')).join();
|
||||
setState(() {
|
||||
_keyController.text = key;
|
||||
_newInvalidFormat = false;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
|
@ -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<ManagePinPukDialog> {
|
||||
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<ManagePinPukDialog> {
|
||||
: 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<ManagePinPukDialog> {
|
||||
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<ManagePinPukDialog> {
|
||||
),
|
||||
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,
|
||||
|
@ -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<PinDialog> {
|
||||
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;
|
||||
|
@ -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)
|
||||
|
101
lib/widgets/app_input_decoration.dart
Normal file
101
lib/widgets/app_input_decoration.dart
Normal file
@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppInputDecoration extends InputDecoration {
|
||||
final List<Widget>? suffixIcons;
|
||||
|
||||
const AppInputDecoration({
|
||||
// allow multiple suffixIcons
|
||||
this.suffixIcons,
|
||||
// forward other TextField parameters
|
||||
super.icon,
|
||||
super.iconColor,
|
||||
super.label,
|
||||
super.labelText,
|
||||
super.labelStyle,
|
||||
super.floatingLabelStyle,
|
||||
super.helperText,
|
||||
super.helperStyle,
|
||||
super.helperMaxLines,
|
||||
super.hintText,
|
||||
super.hintStyle,
|
||||
super.hintTextDirection,
|
||||
super.hintMaxLines,
|
||||
super.hintFadeDuration,
|
||||
super.error,
|
||||
super.errorText,
|
||||
super.errorStyle,
|
||||
super.errorMaxLines,
|
||||
super.floatingLabelBehavior,
|
||||
super.floatingLabelAlignment,
|
||||
super.isCollapsed,
|
||||
super.isDense,
|
||||
super.contentPadding,
|
||||
super.prefixIcon,
|
||||
super.prefixIconConstraints,
|
||||
super.prefix,
|
||||
super.prefixText,
|
||||
super.prefixStyle,
|
||||
super.prefixIconColor,
|
||||
super.suffixIcon,
|
||||
super.suffix,
|
||||
super.suffixText,
|
||||
super.suffixStyle,
|
||||
super.suffixIconColor,
|
||||
super.suffixIconConstraints,
|
||||
super.counter,
|
||||
super.counterText,
|
||||
super.counterStyle,
|
||||
super.filled,
|
||||
super.fillColor,
|
||||
super.focusColor,
|
||||
super.hoverColor,
|
||||
super.errorBorder,
|
||||
super.focusedBorder,
|
||||
super.focusedErrorBorder,
|
||||
super.disabledBorder,
|
||||
super.enabledBorder,
|
||||
super.border,
|
||||
super.enabled = true,
|
||||
super.semanticCounterText,
|
||||
super.alignLabelWithHint,
|
||||
super.constraints,
|
||||
}) : assert(!(suffixIcon != null && suffixIcons != null),
|
||||
'Declaring both suffixIcon and suffixIcons is not supported.');
|
||||
|
||||
@override
|
||||
Widget? get suffixIcon {
|
||||
final icons = [
|
||||
if (super.suffixIcon != null) super.suffixIcon!,
|
||||
if (suffixIcons != null) ...suffixIcons!,
|
||||
if (errorText != null) const Icon(Icons.error_outlined),
|
||||
];
|
||||
|
||||
return switch (icons.length) {
|
||||
0 => null,
|
||||
1 => icons.single,
|
||||
_ => Builder(
|
||||
builder: (context) {
|
||||
// Apply the constraints to *each* icon.
|
||||
final constraints = suffixIconConstraints ??
|
||||
Theme.of(context).visualDensity.effectiveConstraints(
|
||||
const BoxConstraints(
|
||||
minWidth: kMinInteractiveDimension,
|
||||
minHeight: kMinInteractiveDimension,
|
||||
),
|
||||
);
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: [
|
||||
for (Widget icon in icons)
|
||||
ConstrainedBox(
|
||||
constraints: constraints,
|
||||
child: icon,
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import 'package:flutter/material.dart';
|
||||
class ChoiceFilterChip<T> extends StatefulWidget {
|
||||
final T value;
|
||||
final List<T> 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<T> 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<T> extends State<ChoiceFilterChip<T>> {
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
|
||||
return await showMenu(
|
||||
context: context,
|
||||
position: position,
|
||||
@ -79,6 +80,7 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterChip(
|
||||
tooltip: widget.tooltip,
|
||||
avatar: widget.avatar,
|
||||
labelPadding: const EdgeInsets.only(left: 4),
|
||||
label: Row(
|
||||
|
Loading…
Reference in New Issue
Block a user