Added UI and logic

This commit is contained in:
Dennis Fokin 2023-07-11 10:58:06 +02:00
parent 445034151a
commit 13fbfe0f94
No known key found for this signature in database
GPG Key ID: 870B88256690D8BC
4 changed files with 458 additions and 0 deletions

View File

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

View File

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

View File

@ -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<CredentialData>? credentialsFromUri;
const ListScreen(this.devicePath, this.credentialsFromUri)
: super(key: setOrManagePasswordAction);
@override
ConsumerState<ConsumerStatefulWidget> createState() => _ListScreenState();
}
class _ListScreenState extends ConsumerState<ListScreen> {
bool isChecked = true;
int? numCreds;
late Map<CredentialData, bool> checkedCreds;
List<OathCredential>? _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<void> _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.
}
}
}

View File

@ -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<CredentialData>? credentialsFromUri;
final List<OathCredential>? credentials;
const RenameList(
this.device, this.credential, this.credentialsFromUri, this.credentials,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _RenameListState();
}
class _RenameListState extends ConsumerState<RenameList> {
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(),
),
),
);
}
}