diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b429bb71..f157acbf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -285,6 +285,7 @@ "s_accounts": "Accounts", "s_no_accounts": "No accounts", "s_add_account": "Add account", + "s_add_accounts" : "Add account(s)", "s_account_added": "Account added", "l_account_add_failed": "Failed adding account: {message}", "@l_account_add_failed" : { @@ -295,6 +296,7 @@ "l_account_name_required": "Your account must have a name", "l_name_already_exists": "This name already exists for the issuer", "l_invalid_character_issuer": "Invalid character: ':' is not allowed in issuer", + "l_select_accounts" : "Select account(s) to add to the YubiKey", "s_pinned": "Pinned", "s_pin_account": "Pin account", "s_unpin_account": "Unpin account", diff --git a/lib/oath/views/key_actions.dart b/lib/oath/views/key_actions.dart index a096dc13..8fcb100d 100755 --- a/lib/oath/views/key_actions.dart +++ b/lib/oath/views/key_actions.dart @@ -32,6 +32,7 @@ import '../keys.dart' as keys; import 'add_account_page.dart'; import 'manage_password_dialog.dart'; import 'reset_dialog.dart'; +import 'list_screen.dart'; Widget oathBuildActions( BuildContext context, @@ -91,6 +92,47 @@ Widget oathBuildActions( } : null, ), + ActionListItem( + title: l10n.s_qr_scan, + icon: const Icon(Icons.qr_code_scanner_outlined), + onTap: (context) async { + final withContext = ref.read(withContextProvider); + final credentials = ref.read(credentialsProvider); + Navigator.of(context).pop(); + final qrScanner = ref.watch(qrScannerProvider); + if (qrScanner != null) { + final otpauth = await qrScanner.scanQr(); + if (otpauth == null) { + showMessage(context, l10n.l_qr_not_found); + } else { + String s = 'otpauth-migration'; + if (otpauth.contains(s)) { + final data = + CredentialData.multiFromUri(Uri.parse(otpauth)); + await withContext((context) async { + await showBlurDialog( + context: context, + builder: (context) => ListScreen(devicePath, data), + ); + }); + } else if (otpauth.contains('otpauth')) { + final data = + CredentialData.multiFromUri(Uri.parse(otpauth)); + await withContext((context) async { + await showBlurDialog( + context: context, + builder: (context) => OathAddAccountPage( + devicePath, + oathState, + credentials: credentials, + credentialData: data[0], + ), + ); + }); + } + } + } + }), ]), ActionListSection(l10n.s_manage, children: [ ActionListItem( diff --git a/lib/oath/views/list_screen.dart b/lib/oath/views/list_screen.dart new file mode 100644 index 00000000..0058584a --- /dev/null +++ b/lib/oath/views/list_screen.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:yubico_authenticator/app/logging.dart'; + +import '../../android/oath/state.dart'; +import '../../app/models.dart'; +import '../../widgets/responsive_dialog.dart'; + +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../keys.dart'; +import '../models.dart'; +import '../../app/state.dart'; +import '../../core/state.dart'; +import '../state.dart'; +import '../../app/message.dart'; + +import '../../exception/cancellation_exception.dart'; +import 'rename_list_account.dart'; + +final _log = Logger('oath.views.list_screen'); + +class ListScreen extends ConsumerStatefulWidget { + final DevicePath devicePath; + final List? credentialsFromUri; + + const ListScreen(this.devicePath, this.credentialsFromUri) + : super(key: setOrManagePasswordAction); + + @override + ConsumerState createState() => _ListScreenState(); +} + +class _ListScreenState extends ConsumerState { + bool isChecked = true; + int? numCreds; + late Map checkedCreds; + List? _credentials; + + bool unique = true; + + @override + void initState() { + super.initState(); + checkedCreds = + Map.fromIterable(widget.credentialsFromUri!, value: (v) => true); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + numCreds = ref.watch(credentialListProvider(widget.devicePath) + .select((value) => value?.length)); + final deviceNode = ref.watch(currentDeviceProvider); + + _credentials = ref + .watch(credentialListProvider(deviceNode!.path)) + ?.map((e) => e.credential) + .toList(); + + return ResponsiveDialog( + title: Text(l10n.s_add_accounts), + actions: [ + TextButton( + onPressed: isValid() ? submit : null, + child: Text(l10n.s_save), + ) + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Column( + children: [ + Text(l10n.l_select_accounts), + //Padding(padding: EdgeInsets.only(top: 20.0, bottom: 2.0)), + ...widget.credentialsFromUri!.map( + (cred) => CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + secondary: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + onPressed: () async { + _log.debug('pressed'); + }, + icon: const Icon(Icons.touch_app_outlined)), + IconButton( + onPressed: () async { + final node = ref + .watch(currentDeviceDataProvider) + .valueOrNull + ?.node; + final withContext = ref.read(withContextProvider); + CredentialData renamed = await withContext( + (context) async => await showBlurDialog( + context: context, + builder: (context) => RenameList( + node!, + cred, + widget.credentialsFromUri, + _credentials), + )); + + setState(() { + int index = widget.credentialsFromUri!.indexWhere( + (element) => + element.name == cred.name && + (element.issuer == cred.issuer)); + widget.credentialsFromUri![index] = renamed; + checkedCreds[cred] = false; + checkedCreds[renamed] = true; + }); + }, + icon: const Icon(Icons.edit_outlined)), + ]), + title: cred.issuer != null + ? Text(cred.issuer!) + : Text(cred.name), + value: isUnique(cred.name, cred.issuer ?? '') + ? (checkedCreds[cred] ?? true) + : false, + enabled: isUnique(cred.name, cred.issuer ?? ''), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + cred.issuer != null ? Text(cred.name) : Text(''), + isUnique(cred.name, cred.issuer ?? '') + ? Text('') + : Text( + l10n.l_name_already_exists, + style: TextStyle(color: Colors.red), + ) + ]), + onChanged: (bool? value) { + setState(() { + checkedCreds[cred] = value!; + }); + }, + ), + ) + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ))); + } + + bool isUnique(String nameText, String? issuerText) { + bool ans = _credentials + ?.where((element) => + element.name == nameText && + (element.issuer ?? '') == issuerText) + .isEmpty ?? + true; + return ans; + } + + bool isValid() { + int credsToAdd = 0; + checkedCreds.forEach((k, v) => v ? credsToAdd++ : null); + if (numCreds! + credsToAdd <= 32) return true; + return false; + } + + void submit() async { + checkedCreds.forEach((k, v) => v ? accept(k) : null); + Navigator.of(context).pop(); + } + + void accept(CredentialData cred) async { + final deviceNode = ref.watch(currentDeviceProvider); + final devicePath = deviceNode?.path; + if (devicePath != null) { + await _doAddCredential(devicePath: devicePath, credUri: cred.toUri()); + } else if (isAndroid) { + // Send the credential to Android to be added to the next YubiKey + await _doAddCredential(devicePath: null, credUri: cred.toUri()); + } + } + + Future _doAddCredential( + {DevicePath? devicePath, required Uri credUri}) async { + try { + if (devicePath == null) { + assert(isAndroid, 'devicePath is only optional for Android'); + await ref.read(addCredentialToAnyProvider).call(credUri); + } else { + await ref + .read(credentialListProvider(devicePath).notifier) + .addAccount(credUri); + } + if (!mounted) return; + //Navigator.of(context).pop(); + showMessage(context, 'added'); + } on CancellationException catch (_) { + // ignored + } catch (e) { + _log.debug('Failed to add account'); + final String errorMessage; + // TODO: Make this cleaner than importing desktop specific RpcError. + } + } +} diff --git a/lib/oath/views/rename_list_account.dart b/lib/oath/views/rename_list_account.dart new file mode 100644 index 00000000..438a953a --- /dev/null +++ b/lib/oath/views/rename_list_account.dart @@ -0,0 +1,212 @@ +/* + * 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 'package:logging/logging.dart'; + +import '../../app/logging.dart'; +import '../../app/message.dart'; +import '../../app/models.dart'; +import '../../exception/cancellation_exception.dart'; +import '../../desktop/models.dart'; +import '../../widgets/responsive_dialog.dart'; +import '../../widgets/utf8_utils.dart'; +import '../models.dart'; +import '../keys.dart' as keys; +import 'utils.dart'; + +final _log = Logger('oath.view.rename_account_dialog'); + +class RenameList extends ConsumerStatefulWidget { + final DeviceNode device; + final CredentialData credential; + final List? credentialsFromUri; + final List? credentials; + + const RenameList( + this.device, this.credential, this.credentialsFromUri, this.credentials, + {super.key}); + + @override + ConsumerState createState() => _RenameListState(); +} + +class _RenameListState extends ConsumerState { + late String _issuer; + late String _account; + + @override + void initState() { + super.initState(); + _issuer = widget.credential.issuer?.trim() ?? ''; + _account = widget.credential.name.trim(); + } + + void _submit() async { + final l10n = AppLocalizations.of(context)!; + try { + // Rename credentials + final credential = CredentialData( + issuer: _issuer, + name: _account, + oathType: widget.credential.oathType, + secret: widget.credential.secret, + hashAlgorithm: widget.credential.hashAlgorithm, + digits: widget.credential.digits, + counter: widget.credential.counter, + ); + + if (!mounted) return; + Navigator.of(context).pop(credential); + showMessage(context, l10n.s_account_renamed); + } on CancellationException catch (_) { + // ignored + } catch (e) { + _log.error('Failed to add account', e); + 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_account_add_failed(errorMessage), + duration: const Duration(seconds: 4), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final credential = widget.credential; + + final remaining = getRemainingKeySpace( + oathType: credential.oathType, + period: credential.period, + issuer: _issuer, + name: _account, + ); + final issuerRemaining = remaining.first; + final nameRemaining = remaining.second; + + // is this credentials name/issuer pair different from all other? + final isUniqueFromUri = widget.credentialsFromUri + ?.where((element) => + element != credential && + element.name == _account && + (element.issuer ?? '') == _issuer) + .isEmpty ?? + false; + + final isUniqueFromDevice = widget.credentials + ?.where((element) => + element != credential && + element.name == _account && + (element.issuer ?? '') == _issuer) + .isEmpty ?? + false; + + // is this credential name/issuer of valid format + final isValidFormat = _account.isNotEmpty; + + // are the name/issuer values different from original + final didChange = (widget.credential.issuer ?? '') != _issuer || + widget.credential.name != _account; + + // can we rename with the new values + final isValid = isUniqueFromUri && isUniqueFromDevice && isValidFormat; + + return ResponsiveDialog( + title: Text(l10n.s_rename_account), + actions: [ + TextButton( + onPressed: didChange && isValid ? _submit : null, + key: keys.saveButton, + child: Text(l10n.s_save), + ), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.q_rename_target(credential.name)), + Text(l10n.p_rename_will_change_account_displayed), + TextFormField( + initialValue: _issuer, + enabled: issuerRemaining > 0, + maxLength: issuerRemaining > 0 ? issuerRemaining : null, + buildCounter: buildByteCounterFor(_issuer), + inputFormatters: [limitBytesLength(issuerRemaining)], + key: keys.issuerField, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_issuer_optional, + helperText: '', // Prevents dialog resizing when disabled + prefixIcon: const Icon(Icons.business_outlined), + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _issuer = value.trim(); + }); + }, + ), + TextFormField( + initialValue: _account, + maxLength: nameRemaining, + inputFormatters: [limitBytesLength(nameRemaining)], + buildCounter: buildByteCounterFor(_account), + key: keys.nameField, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_account_name, + helperText: '', // Prevents dialog resizing when disabled + errorText: !isValidFormat + ? l10n.l_account_name_required + : (!isUniqueFromUri || !isUniqueFromDevice) + ? l10n.l_name_already_exists + : null, + prefixIcon: const Icon(Icons.people_alt_outlined), + ), + textInputAction: TextInputAction.done, + onChanged: (value) { + setState(() { + _account = value.trim(); + }); + }, + onFieldSubmitted: (_) { + if (didChange && isValid) { + _submit(); + } + }, + ), + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ), + ), + ); + } +}