mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2025-01-08 20:08:45 +03:00
Merge PR #218.
This commit is contained in:
commit
62a7365439
@ -20,7 +20,12 @@ class MainViewModel : ViewModel() {
|
||||
|
||||
private var _appContext = MutableLiveData(OperationContext.Oath)
|
||||
val appContext: LiveData<OperationContext> = _appContext
|
||||
fun setAppContext(appContext: OperationContext) = _appContext.postValue(appContext)
|
||||
fun setAppContext(appContext: OperationContext) {
|
||||
// Don't reset the context unless it actually changes
|
||||
if(appContext != _appContext.value) {
|
||||
_appContext.postValue(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
private val _connectedYubiKey = MutableLiveData<UsbYubiKeyDevice?>()
|
||||
val connectedYubiKey: LiveData<UsbYubiKeyDevice?> = _connectedYubiKey
|
||||
|
@ -52,6 +52,7 @@ class OathManager(
|
||||
|
||||
private var pendingAction: OathAction? = null
|
||||
private var refreshJob: Job? = null
|
||||
private var addToAny = false
|
||||
|
||||
// provides actions for lifecycle events
|
||||
private val lifecycleObserver = object : DefaultLifecycleObserver {
|
||||
@ -61,14 +62,16 @@ class OathManager(
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
startTimeMs = currentTimeMs
|
||||
|
||||
// cancel any pending actions
|
||||
pendingAction?.let {
|
||||
Log.d(TAG, "Cancelling pending action/closing nfc dialog.")
|
||||
it.invoke(Result.failure(CancellationException()))
|
||||
coroutineScope.launch {
|
||||
dialogManager.closeDialog()
|
||||
// cancel any pending actions, except for addToAny
|
||||
if(!addToAny) {
|
||||
pendingAction?.let {
|
||||
Log.d(TAG, "Cancelling pending action/closing nfc dialog.")
|
||||
it.invoke(Result.failure(CancellationException()))
|
||||
coroutineScope.launch {
|
||||
dialogManager.closeDialog()
|
||||
}
|
||||
pendingAction = null
|
||||
}
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
super.onPause(owner)
|
||||
@ -150,6 +153,10 @@ class OathManager(
|
||||
args["issuer"] as String?
|
||||
)
|
||||
"deleteAccount" -> deleteAccount(args["credentialId"] as String)
|
||||
"addAccountToAny" -> addAccountToAny(
|
||||
args["uri"] as String,
|
||||
args["requireTouch"] as Boolean
|
||||
)
|
||||
else -> throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
@ -190,13 +197,6 @@ class OathManager(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Awaiting an action for a different device? Fail it and stop processing.
|
||||
pendingAction?.let { action ->
|
||||
action.invoke(Result.failure(IllegalStateException("Wrong deviceId")))
|
||||
pendingAction = null
|
||||
return@withConnection
|
||||
}
|
||||
|
||||
// Clear in-memory password for any previous device
|
||||
if (connection.transport == Transport.NFC && previousId != null) {
|
||||
memoryKeyProvider.removeKey(previousId)
|
||||
@ -210,6 +210,20 @@ class OathManager(
|
||||
)
|
||||
}
|
||||
|
||||
// Awaiting an action for a different or no device?
|
||||
pendingAction?.let { action ->
|
||||
pendingAction = null
|
||||
if(addToAny) {
|
||||
// Special "add to any YubiKey" action, process
|
||||
addToAny = false
|
||||
action.invoke(Result.success(oath))
|
||||
} else {
|
||||
// Awaiting an action for a different device? Fail it and stop processing.
|
||||
action.invoke(Result.failure(IllegalStateException("Wrong deviceId")))
|
||||
return@withConnection
|
||||
}
|
||||
}
|
||||
|
||||
// Update deviceInfo since the deviceId has changed
|
||||
if (oath.version.isLessThan(4, 0, 0) && connection.transport == Transport.NFC) {
|
||||
// NEO over NFC, need a new connection to select another applet
|
||||
@ -259,6 +273,32 @@ class OathManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addAccountToAny(
|
||||
uri: String,
|
||||
requireTouch: Boolean,
|
||||
): String {
|
||||
val credentialData: CredentialData =
|
||||
CredentialData.parseUri(URI.create(uri))
|
||||
addToAny = true
|
||||
return useOathSessionNfc("Add account") { session ->
|
||||
val credential = session.putCredential(credentialData, requireTouch)
|
||||
|
||||
val code =
|
||||
if (credentialData.oathType == OathType.TOTP && !requireTouch) {
|
||||
// recalculate the code
|
||||
calculateCode(session, credential)
|
||||
} else null
|
||||
|
||||
val addedCred = oathViewModel.addCredential(
|
||||
credential.model(session.deviceId),
|
||||
code?.model()
|
||||
)
|
||||
|
||||
Log.d(TAG, "Added cred $credential")
|
||||
jsonSerializer.encodeToString(addedCred)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reset(): String {
|
||||
useOathSession("Reset YubiKey") {
|
||||
// note, it is ok to reset locked session
|
||||
|
@ -108,6 +108,26 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
final addCredentialToAnyProvider =
|
||||
Provider((ref) => (Uri credentialUri, {bool requireTouch = false}) async {
|
||||
try {
|
||||
String resultString = await _methods.invokeMethod(
|
||||
'addAccountToAny', {
|
||||
'uri': credentialUri.toString(),
|
||||
'requireTouch': requireTouch
|
||||
});
|
||||
|
||||
var result = jsonDecode(resultString);
|
||||
return OathCredential.fromJson(result['credential']);
|
||||
} on PlatformException catch (pe) {
|
||||
if (CancellationException.isCancellation(pe)) {
|
||||
throw CancellationException();
|
||||
}
|
||||
_log.error('Failed to add account.', pe);
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
|
||||
final androidCredentialListProvider = StateNotifierProvider.autoDispose
|
||||
.family<OathCredentialListNotifier, List<OathPair>?, DevicePath>(
|
||||
(ref, devicePath) {
|
||||
|
@ -11,6 +11,7 @@ class AppPage extends ConsumerWidget {
|
||||
final List<Widget> actions;
|
||||
final List<PopupMenuEntry> keyActions;
|
||||
final bool centered;
|
||||
final Widget Function(List<PopupMenuEntry>)? actionButtonBuilder;
|
||||
AppPage({
|
||||
super.key,
|
||||
this.title,
|
||||
@ -18,6 +19,7 @@ class AppPage extends ConsumerWidget {
|
||||
this.actions = const [],
|
||||
this.keyActions = const [],
|
||||
this.centered = false,
|
||||
this.actionButtonBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -89,7 +91,8 @@ class AppPage extends ConsumerWidget {
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: DeviceButton(actions: keyActions),
|
||||
child: actionButtonBuilder?.call(keyActions) ??
|
||||
DeviceButton(actions: keyActions),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -2,12 +2,15 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:yubico_authenticator/core/state.dart';
|
||||
|
||||
import 'message_page.dart';
|
||||
import 'device_error_screen.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import '../message.dart';
|
||||
import '../../fido/views/fido_screen.dart';
|
||||
import '../../oath/views/add_account_page.dart';
|
||||
import '../../oath/views/oath_screen.dart';
|
||||
|
||||
class MainPage extends ConsumerWidget {
|
||||
@ -31,13 +34,37 @@ class MainPage extends ConsumerWidget {
|
||||
'settings',
|
||||
'about',
|
||||
'licenses',
|
||||
'user_interaction_prompt',
|
||||
'oath_add_account',
|
||||
].contains(route.settings.name);
|
||||
});
|
||||
});
|
||||
|
||||
final deviceNode = ref.watch(currentDeviceProvider);
|
||||
if (deviceNode == null) {
|
||||
return MessagePage(message: Platform.isAndroid ? 'Insert or tap your YubiKey' : 'Insert your YubiKey');
|
||||
if (isAndroid) {
|
||||
return MessagePage(
|
||||
message: 'Insert or tap your YubiKey',
|
||||
actionButtonBuilder: (keyActions) => IconButton(
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
tooltip: 'Add account',
|
||||
onPressed: () {
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
routeSettings: const RouteSettings(name: 'oath_add_account'),
|
||||
builder: (context) => OathAddAccountPage(
|
||||
null,
|
||||
null,
|
||||
openQrScanner: Platform.isAndroid,
|
||||
credentials: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const MessagePage(message: 'Insert your YubiKey');
|
||||
}
|
||||
} else {
|
||||
return ref.watch(currentDeviceDataProvider).when(
|
||||
data: (data) {
|
||||
@ -45,12 +72,14 @@ class MainPage extends ConsumerWidget {
|
||||
if (app.getAvailability(data) == Availability.unsupported) {
|
||||
return MessagePage(
|
||||
header: 'Application not supported',
|
||||
message: 'The used YubiKey does not support \'${app.name}\' application',
|
||||
message:
|
||||
'The used YubiKey does not support \'${app.name}\' application',
|
||||
);
|
||||
} else if (app.getAvailability(data) != Availability.enabled) {
|
||||
return MessagePage(
|
||||
header: 'Application disabled',
|
||||
message: 'Enable the \'${app.name}\' application on your YubiKey to access',
|
||||
message:
|
||||
'Enable the \'${app.name}\' application on your YubiKey to access',
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ class MessagePage extends StatelessWidget {
|
||||
final String? message;
|
||||
final List<Widget> actions;
|
||||
final List<PopupMenuEntry> keyActions;
|
||||
final Widget Function(List<PopupMenuEntry> keyActions)? actionButtonBuilder;
|
||||
|
||||
const MessagePage({
|
||||
super.key,
|
||||
@ -18,6 +19,7 @@ class MessagePage extends StatelessWidget {
|
||||
this.message,
|
||||
this.actions = const [],
|
||||
this.keyActions = const [],
|
||||
this.actionButtonBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -26,6 +28,7 @@ class MessagePage extends StatelessWidget {
|
||||
centered: true,
|
||||
actions: actions,
|
||||
keyActions: keyActions,
|
||||
actionButtonBuilder: actionButtonBuilder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../message.dart';
|
||||
@ -50,7 +52,7 @@ class _UserInteractionDialog extends StatefulWidget {
|
||||
|
||||
class _UserInteractionDialogState extends State<_UserInteractionDialog> {
|
||||
void _rebuild() {
|
||||
setState(() {});
|
||||
Timer.run(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
@ -126,6 +128,7 @@ UserInteractionController promptUserInteraction(
|
||||
);
|
||||
showBlurDialog(
|
||||
context: context,
|
||||
routeSettings: const RouteSettings(name: 'user_interaction_prompt'),
|
||||
builder: (context) {
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
|
@ -1,4 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@ -7,12 +9,15 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../android/oath/state.dart';
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../app/views/user_interaction.dart';
|
||||
import '../../cancellation_exception.dart';
|
||||
import '../../desktop/models.dart';
|
||||
import '../../management/models.dart';
|
||||
import '../../widgets/choice_filter_chip.dart';
|
||||
import '../../widgets/file_drop_target.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
@ -20,6 +25,7 @@ import '../../widgets/utf8_utils.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'unlock_form.dart';
|
||||
import 'utils.dart';
|
||||
|
||||
final _log = Logger('oath.view.add_account_page');
|
||||
@ -30,8 +36,8 @@ final _secretFormatterPattern =
|
||||
enum _QrScanState { none, scanning, success, failed }
|
||||
|
||||
class OathAddAccountPage extends ConsumerStatefulWidget {
|
||||
final DevicePath devicePath;
|
||||
final OathState state;
|
||||
final DevicePath? devicePath;
|
||||
final OathState? state;
|
||||
final List<OathCredential>? credentials;
|
||||
final bool openQrScanner;
|
||||
const OathAddAccountPage(
|
||||
@ -52,6 +58,8 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
final _accountController = TextEditingController();
|
||||
final _secretController = TextEditingController();
|
||||
final _periodController = TextEditingController(text: '$defaultPeriod');
|
||||
UserInteractionController? _promptController;
|
||||
Uri? _otpauthUri;
|
||||
bool _touch = false;
|
||||
OathType _oathType = defaultOathType;
|
||||
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
|
||||
@ -147,8 +155,91 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doAddCredential(
|
||||
{DevicePath? devicePath, required Uri credUri}) async {
|
||||
try {
|
||||
if (devicePath == null) {
|
||||
assert(Platform.isAndroid, 'devicePath is only optional for Android');
|
||||
await ref
|
||||
.read(addCredentialToAnyProvider)
|
||||
.call(credUri, requireTouch: _touch);
|
||||
} else {
|
||||
await ref
|
||||
.read(credentialListProvider(devicePath).notifier)
|
||||
.addAccount(credUri, requireTouch: _touch);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showMessage(
|
||||
context, AppLocalizations.of(context)!.oath_success_add_account);
|
||||
} 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,
|
||||
'${AppLocalizations.of(context)!.oath_fail_add_account}: $errorMessage',
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deviceNode = ref.watch(currentDeviceProvider);
|
||||
if (widget.devicePath != null && widget.devicePath != deviceNode?.path) {
|
||||
// If the dialog was started for a specific device and it was
|
||||
// changed/removed, close the dialog.
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
final OathState? oathState;
|
||||
if (widget.state == null && deviceNode != null) {
|
||||
oathState = ref
|
||||
.watch(oathStateProvider(deviceNode.path))
|
||||
.maybeWhen(data: (data) => data, orElse: () => null);
|
||||
} else {
|
||||
oathState = widget.state;
|
||||
}
|
||||
|
||||
final otpauthUri = _otpauthUri;
|
||||
_promptController?.updateContent(title: 'Insert YubiKey');
|
||||
if (otpauthUri != null && deviceNode != null) {
|
||||
final deviceData = ref.watch(currentDeviceDataProvider);
|
||||
deviceData.when(data: (data) {
|
||||
if (Capability.oath.value ^
|
||||
(data.info.config.enabledCapabilities[deviceNode.transport] ??
|
||||
0) !=
|
||||
0) {
|
||||
if (oathState == null) {
|
||||
_promptController?.updateContent(title: 'Please wait...');
|
||||
} else if (oathState.locked) {
|
||||
_promptController?.close();
|
||||
} else {
|
||||
_otpauthUri = null;
|
||||
_promptController?.close();
|
||||
Timer.run(() => _doAddCredential(
|
||||
devicePath: deviceNode.path,
|
||||
credUri: otpauthUri,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
_promptController?.updateContent(title: 'Unsupported YubiKey');
|
||||
}
|
||||
}, error: (error, _) {
|
||||
_promptController?.updateContent(title: 'Unsupported YubiKey');
|
||||
}, loading: () {
|
||||
_promptController?.updateContent(title: 'Please wait...');
|
||||
});
|
||||
}
|
||||
|
||||
final period = int.tryParse(_periodController.text) ?? -1;
|
||||
final remaining = getRemainingKeySpace(
|
||||
oathType: _oathType,
|
||||
@ -170,7 +261,10 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
.isEmpty ??
|
||||
true;
|
||||
|
||||
final isValid = _accountController.text.trim().isNotEmpty &&
|
||||
final isLocked = oathState?.locked ?? false;
|
||||
|
||||
final isValid = !isLocked &&
|
||||
_accountController.text.trim().isNotEmpty &&
|
||||
secret.isNotEmpty &&
|
||||
isUnique &&
|
||||
issuerRemaining >= -1 &&
|
||||
@ -179,6 +273,20 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
|
||||
final qrScanner = ref.watch(qrScannerProvider);
|
||||
|
||||
final hashAlgorithms = HashAlgorithm.values
|
||||
.where((alg) =>
|
||||
alg != HashAlgorithm.sha512 ||
|
||||
(oathState?.version.isAtLeast(4, 3, 1) ?? true))
|
||||
.toList();
|
||||
if (!hashAlgorithms.contains(_hashAlgorithm)) {
|
||||
_hashAlgorithm = HashAlgorithm.sha1;
|
||||
}
|
||||
|
||||
if (!(oathState?.version.isAtLeast(4, 2) ?? true)) {
|
||||
// Touch not supported
|
||||
_touch = false;
|
||||
}
|
||||
|
||||
void submit() async {
|
||||
if (secretLengthValid) {
|
||||
final issuer = _issuerController.text.trim();
|
||||
@ -193,29 +301,23 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
period: period,
|
||||
);
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(credentialListProvider(widget.devicePath).notifier)
|
||||
.addAccount(cred.toUri(), requireTouch: _touch);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showMessage(
|
||||
context, AppLocalizations.of(context)!.oath_success_add_account);
|
||||
} 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(
|
||||
final devicePath = deviceNode?.path;
|
||||
if (devicePath != null) {
|
||||
await _doAddCredential(devicePath: devicePath, credUri: cred.toUri());
|
||||
} else if (Platform.isAndroid) {
|
||||
// Send the credential to Android to be added to the next YubiKey
|
||||
await _doAddCredential(devicePath: null, credUri: cred.toUri());
|
||||
} else {
|
||||
// Desktop. No YubiKey, prompt and store the cred.
|
||||
_otpauthUri = cred.toUri();
|
||||
_promptController = promptUserInteraction(
|
||||
context,
|
||||
'${AppLocalizations.of(context)!.oath_fail_add_account}: $errorMessage',
|
||||
duration: const Duration(seconds: 4),
|
||||
title: 'Insert YubiKey',
|
||||
description: 'Add account',
|
||||
icon: const Icon(Icons.usb),
|
||||
onCancel: () {
|
||||
_otpauthUri = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@ -249,198 +351,217 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
key: keys.issuerField,
|
||||
controller: _issuerController,
|
||||
autofocus: !widget.openQrScanner,
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: max(issuerRemaining, 1),
|
||||
inputFormatters: [limitBytesLength(issuerRemaining)],
|
||||
buildCounter:
|
||||
buildByteCounterFor(_issuerController.text.trim()),
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.oath_issuer_optional,
|
||||
helperText: '', // Prevents dialog resizing when disabled
|
||||
prefixIcon: const Icon(Icons.business_outlined),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (isValid) submit();
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
key: keys.nameField,
|
||||
controller: _accountController,
|
||||
maxLength: max(nameRemaining, 1),
|
||||
buildCounter:
|
||||
buildByteCounterFor(_accountController.text.trim()),
|
||||
inputFormatters: [limitBytesLength(nameRemaining)],
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
labelText: AppLocalizations.of(context)!.oath_account_name,
|
||||
helperText: '', // Prevents dialog resizing when disabled
|
||||
errorText: isUnique
|
||||
? null
|
||||
: AppLocalizations.of(context)!.oath_duplicate_name,
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (isValid) submit();
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
key: keys.secretField,
|
||||
controller: _secretController,
|
||||
obscureText: _isObscure,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(_secretFormatterPattern)
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isObscure ? Icons.visibility : Icons.visibility_off,
|
||||
color: IconTheme.of(context).color,
|
||||
child: isLocked
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child:
|
||||
UnlockForm(deviceNode!.path, keystore: oathState!.keystore),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
key: keys.issuerField,
|
||||
controller: _issuerController,
|
||||
autofocus: !widget.openQrScanner,
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: max(issuerRemaining, 1),
|
||||
inputFormatters: [limitBytesLength(issuerRemaining)],
|
||||
buildCounter:
|
||||
buildByteCounterFor(_issuerController.text.trim()),
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText:
|
||||
AppLocalizations.of(context)!.oath_issuer_optional,
|
||||
helperText:
|
||||
'', // Prevents dialog resizing when disabled
|
||||
prefixIcon: const Icon(Icons.business_outlined),
|
||||
),
|
||||
onPressed: () {
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isObscure = !_isObscure;
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.key_outlined),
|
||||
labelText: AppLocalizations.of(context)!.oath_secret_key,
|
||||
errorText: _validateSecretLength && !secretLengthValid
|
||||
? AppLocalizations.of(context)!.oath_invalid_length
|
||||
: null),
|
||||
readOnly: _qrState == _QrScanState.success,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validateSecretLength = false;
|
||||
});
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (isValid) submit();
|
||||
},
|
||||
),
|
||||
if (qrScanner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: ActionChip(
|
||||
avatar: _qrState != _QrScanState.scanning
|
||||
? (_qrState == _QrScanState.success
|
||||
? const Icon(Icons.qr_code)
|
||||
: const Icon(Icons.qr_code_scanner_outlined))
|
||||
: const CircularProgressIndicator(strokeWidth: 2.0),
|
||||
label: _qrState == _QrScanState.success
|
||||
? Text(AppLocalizations.of(context)!.oath_scanned_qr)
|
||||
: Text(AppLocalizations.of(context)!.oath_scan_qr),
|
||||
onPressed: () {
|
||||
_scanQrCode(qrScanner);
|
||||
}),
|
||||
),
|
||||
const Divider(),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
if (widget.state.version.isAtLeast(4, 2))
|
||||
FilterChip(
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.oath_require_touch),
|
||||
selected: _touch,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_touch = value;
|
||||
});
|
||||
onSubmitted: (_) {
|
||||
if (isValid) submit();
|
||||
},
|
||||
),
|
||||
ChoiceFilterChip<OathType>(
|
||||
items: OathType.values,
|
||||
value: _oathType,
|
||||
selected: _oathType != defaultOathType,
|
||||
itemBuilder: (value) => Text(value.displayName),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (value) {
|
||||
setState(() {
|
||||
_oathType = value;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ChoiceFilterChip<HashAlgorithm>(
|
||||
items: HashAlgorithm.values,
|
||||
value: _hashAlgorithm,
|
||||
selected: _hashAlgorithm != defaultHashAlgorithm,
|
||||
itemBuilder: (value) => Text(value.displayName),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (value) {
|
||||
setState(() {
|
||||
_hashAlgorithm = value;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (_oathType == OathType.totp)
|
||||
ChoiceFilterChip<int>(
|
||||
items: _periodValues,
|
||||
value:
|
||||
int.tryParse(_periodController.text) ?? defaultPeriod,
|
||||
selected:
|
||||
int.tryParse(_periodController.text) != defaultPeriod,
|
||||
itemBuilder: ((value) => Text(
|
||||
'$value ${AppLocalizations.of(context)!.oath_sec}')),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (period) {
|
||||
TextField(
|
||||
key: keys.nameField,
|
||||
controller: _accountController,
|
||||
maxLength: max(nameRemaining, 1),
|
||||
buildCounter:
|
||||
buildByteCounterFor(_accountController.text.trim()),
|
||||
inputFormatters: [limitBytesLength(nameRemaining)],
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
labelText:
|
||||
AppLocalizations.of(context)!.oath_account_name,
|
||||
helperText:
|
||||
'', // Prevents dialog resizing when disabled
|
||||
errorText: isUnique
|
||||
? null
|
||||
: AppLocalizations.of(context)!.oath_duplicate_name,
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (isValid) submit();
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
key: keys.secretField,
|
||||
controller: _secretController,
|
||||
obscureText: _isObscure,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(
|
||||
_secretFormatterPattern)
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isObscure
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: IconTheme.of(context).color,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_periodController.text = '$period';
|
||||
_isObscure = !_isObscure;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
},
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.key_outlined),
|
||||
labelText:
|
||||
AppLocalizations.of(context)!.oath_secret_key,
|
||||
errorText: _validateSecretLength && !secretLengthValid
|
||||
? AppLocalizations.of(context)!
|
||||
.oath_invalid_length
|
||||
: null),
|
||||
readOnly: _qrState == _QrScanState.success,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validateSecretLength = false;
|
||||
});
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (isValid) submit();
|
||||
},
|
||||
),
|
||||
ChoiceFilterChip<int>(
|
||||
items: _digitsValues,
|
||||
value: _digits,
|
||||
selected: _digits != defaultDigits,
|
||||
itemBuilder: (value) => Text(
|
||||
'$value ${AppLocalizations.of(context)!.oath_digits}'),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (digits) {
|
||||
setState(() {
|
||||
_digits = digits;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
if (qrScanner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: ActionChip(
|
||||
avatar: _qrState != _QrScanState.scanning
|
||||
? (_qrState == _QrScanState.success
|
||||
? const Icon(Icons.qr_code)
|
||||
: const Icon(
|
||||
Icons.qr_code_scanner_outlined))
|
||||
: const CircularProgressIndicator(
|
||||
strokeWidth: 2.0),
|
||||
label: _qrState == _QrScanState.success
|
||||
? Text(AppLocalizations.of(context)!
|
||||
.oath_scanned_qr)
|
||||
: Text(
|
||||
AppLocalizations.of(context)!.oath_scan_qr),
|
||||
onPressed: () {
|
||||
_scanQrCode(qrScanner);
|
||||
}),
|
||||
),
|
||||
const Divider(),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
if (oathState?.version.isAtLeast(4, 2) ?? true)
|
||||
FilterChip(
|
||||
label: Text(AppLocalizations.of(context)!
|
||||
.oath_require_touch),
|
||||
selected: _touch,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_touch = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
ChoiceFilterChip<OathType>(
|
||||
items: OathType.values,
|
||||
value: _oathType,
|
||||
selected: _oathType != defaultOathType,
|
||||
itemBuilder: (value) => Text(value.displayName),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (value) {
|
||||
setState(() {
|
||||
_oathType = value;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ChoiceFilterChip<HashAlgorithm>(
|
||||
items: hashAlgorithms,
|
||||
value: _hashAlgorithm,
|
||||
selected: _hashAlgorithm != defaultHashAlgorithm,
|
||||
itemBuilder: (value) => Text(value.displayName),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (value) {
|
||||
setState(() {
|
||||
_hashAlgorithm = value;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (_oathType == OathType.totp)
|
||||
ChoiceFilterChip<int>(
|
||||
items: _periodValues,
|
||||
value: int.tryParse(_periodController.text) ??
|
||||
defaultPeriod,
|
||||
selected: int.tryParse(_periodController.text) !=
|
||||
defaultPeriod,
|
||||
itemBuilder: ((value) => Text(
|
||||
'$value ${AppLocalizations.of(context)!.oath_sec}')),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (period) {
|
||||
setState(() {
|
||||
_periodController.text = '$period';
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ChoiceFilterChip<int>(
|
||||
items: _digitsValues,
|
||||
value: _digits,
|
||||
selected: _digits != defaultDigits,
|
||||
itemBuilder: (value) => Text(
|
||||
'$value ${AppLocalizations.of(context)!.oath_digits}'),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (digits) {
|
||||
setState(() {
|
||||
_digits = digits;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import 'account_list.dart';
|
||||
import 'add_account_page.dart';
|
||||
import 'manage_password_dialog.dart';
|
||||
import 'reset_dialog.dart';
|
||||
import 'unlock_form.dart';
|
||||
|
||||
class OathScreen extends ConsumerWidget {
|
||||
final DevicePath devicePath;
|
||||
@ -78,13 +79,12 @@ class _LockedView extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
_UnlockForm(
|
||||
devicePath,
|
||||
keystore: oathState.keystore,
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: UnlockForm(
|
||||
devicePath,
|
||||
keystore: oathState.keystore,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -244,122 +244,3 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _UnlockForm extends ConsumerStatefulWidget {
|
||||
final DevicePath _devicePath;
|
||||
final KeystoreState keystore;
|
||||
const _UnlockForm(this._devicePath, {required this.keystore});
|
||||
|
||||
@override
|
||||
ConsumerState<_UnlockForm> createState() => _UnlockFormState();
|
||||
}
|
||||
|
||||
class _UnlockFormState extends ConsumerState<_UnlockForm> {
|
||||
final _passwordController = TextEditingController();
|
||||
bool _remember = false;
|
||||
bool _passwordIsWrong = false;
|
||||
bool _isObscure = true;
|
||||
|
||||
void _submit() async {
|
||||
setState(() {
|
||||
_passwordIsWrong = false;
|
||||
});
|
||||
final result = await ref
|
||||
.read(oathStateProvider(widget._devicePath).notifier)
|
||||
.unlock(_passwordController.text, remember: _remember);
|
||||
if (!mounted) return;
|
||||
if (!result.first) {
|
||||
setState(() {
|
||||
_passwordIsWrong = true;
|
||||
_passwordController.clear();
|
||||
});
|
||||
} else if (_remember && !result.second) {
|
||||
showMessage(
|
||||
context, AppLocalizations.of(context)!.oath_failed_remember_pw);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keystoreFailed = widget.keystore == KeystoreState.failed;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 18.0, right: 18, top: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.oath_enter_oath_pw,
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
TextField(
|
||||
key: keys.passwordField,
|
||||
controller: _passwordController,
|
||||
autofocus: true,
|
||||
obscureText: _isObscure,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.oath_password,
|
||||
errorText: _passwordIsWrong
|
||||
? AppLocalizations.of(context)!.oath_wrong_password
|
||||
: null,
|
||||
helperText: '', // Prevents resizing when errorText shown
|
||||
prefixIcon: const Icon(Icons.password_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isObscure ? Icons.visibility : Icons.visibility_off,
|
||||
color: IconTheme.of(context).color,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isObscure = !_isObscure;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {
|
||||
_passwordIsWrong = false;
|
||||
}), // Update state on change
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
keystoreFailed
|
||||
? ListTile(
|
||||
leading: const Icon(Icons.warning_amber_rounded),
|
||||
title: Text(
|
||||
AppLocalizations.of(context)!.oath_keystore_unavailable),
|
||||
dense: true,
|
||||
minLeadingWidth: 0,
|
||||
)
|
||||
: CheckboxListTile(
|
||||
title:
|
||||
Text(AppLocalizations.of(context)!.oath_remember_password),
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: _remember,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_remember = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0, right: 18.0, bottom: 4.0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton.icon(
|
||||
key: keys.unlockButton,
|
||||
label: Text(AppLocalizations.of(context)!.oath_unlock),
|
||||
icon: const Icon(Icons.lock_open),
|
||||
onPressed: _passwordController.text.isNotEmpty ? _submit : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
127
lib/oath/views/unlock_form.dart
Executable file
127
lib/oath/views/unlock_form.dart
Executable file
@ -0,0 +1,127 @@
|
||||
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 '../models.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import '../state.dart';
|
||||
|
||||
class UnlockForm extends ConsumerStatefulWidget {
|
||||
final DevicePath _devicePath;
|
||||
final KeystoreState keystore;
|
||||
const UnlockForm(this._devicePath, {required this.keystore, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<UnlockForm> createState() => _UnlockFormState();
|
||||
}
|
||||
|
||||
class _UnlockFormState extends ConsumerState<UnlockForm> {
|
||||
final _passwordController = TextEditingController();
|
||||
bool _remember = false;
|
||||
bool _passwordIsWrong = false;
|
||||
bool _isObscure = true;
|
||||
|
||||
void _submit() async {
|
||||
setState(() {
|
||||
_passwordIsWrong = false;
|
||||
});
|
||||
final result = await ref
|
||||
.read(oathStateProvider(widget._devicePath).notifier)
|
||||
.unlock(_passwordController.text, remember: _remember);
|
||||
if (!mounted) return;
|
||||
if (!result.first) {
|
||||
setState(() {
|
||||
_passwordIsWrong = true;
|
||||
_passwordController.clear();
|
||||
});
|
||||
} else if (_remember && !result.second) {
|
||||
showMessage(
|
||||
context, AppLocalizations.of(context)!.oath_failed_remember_pw);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keystoreFailed = widget.keystore == KeystoreState.failed;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.oath_enter_oath_pw,
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
TextField(
|
||||
key: keys.passwordField,
|
||||
controller: _passwordController,
|
||||
autofocus: true,
|
||||
obscureText: _isObscure,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.oath_password,
|
||||
errorText: _passwordIsWrong
|
||||
? AppLocalizations.of(context)!.oath_wrong_password
|
||||
: null,
|
||||
helperText: '', // Prevents resizing when errorText shown
|
||||
prefixIcon: const Icon(Icons.password_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isObscure ? Icons.visibility : Icons.visibility_off,
|
||||
color: IconTheme.of(context).color,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isObscure = !_isObscure;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {
|
||||
_passwordIsWrong = false;
|
||||
}), // Update state on change
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
keystoreFailed
|
||||
? ListTile(
|
||||
leading: const Icon(Icons.warning_amber_rounded),
|
||||
title: Text(
|
||||
AppLocalizations.of(context)!.oath_keystore_unavailable),
|
||||
dense: true,
|
||||
minLeadingWidth: 0,
|
||||
)
|
||||
: CheckboxListTile(
|
||||
title:
|
||||
Text(AppLocalizations.of(context)!.oath_remember_password),
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: _remember,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_remember = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton.icon(
|
||||
key: keys.unlockButton,
|
||||
label: Text(AppLocalizations.of(context)!.oath_unlock),
|
||||
icon: const Icon(Icons.lock_open),
|
||||
onPressed: _passwordController.text.isNotEmpty ? _submit : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user