mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 10:11:52 +03:00
Added UI and logic
This commit is contained in:
parent
445034151a
commit
13fbfe0f94
@ -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",
|
||||
|
@ -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(
|
||||
|
202
lib/oath/views/list_screen.dart
Normal file
202
lib/oath/views/list_screen.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
212
lib/oath/views/rename_list_account.dart
Normal file
212
lib/oath/views/rename_list_account.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user