diff --git a/lib/app/message.dart b/lib/app/message.dart index 2d94d3b6..42aaa3c2 100755 --- a/lib/app/message.dart +++ b/lib/app/message.dart @@ -47,6 +47,7 @@ class _BottomMenu extends ConsumerWidget { .map((a) => ListTile( leading: a.icon, title: Text(a.text), + enabled: a.action != null, onTap: a.action == null ? null : () { diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index a80bb8fd..fafdf8f8 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'message_page.dart'; import 'no_device_screen.dart'; import 'device_info_screen.dart'; import '../models.dart'; @@ -21,8 +22,9 @@ class MainPage extends ConsumerWidget { } final app = ref.watch(currentAppProvider); if (app.getAvailability(deviceData) != Availability.enabled) { - return const Center( - child: Text('This application is disabled'), + return const MessagePage( + header: 'Application disabled', + message: 'Enable the application on your YubiKey to access', ); } diff --git a/lib/app/views/message_page.dart b/lib/app/views/message_page.dart new file mode 100755 index 00000000..4feb3a9c --- /dev/null +++ b/lib/app/views/message_page.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'app_page.dart'; + +class MessagePage extends StatelessWidget { + final Widget? title; + final String header; + final String message; + final Widget? floatingActionButton; + + const MessagePage({ + Key? key, + this.title, + required this.header, + required this.message, + this.floatingActionButton, + }) : super(key: key); + + @override + Widget build(BuildContext context) => AppPage( + title: title, + centered: true, + child: Column( + children: [ + Text(header, style: Theme.of(context).textTheme.headline6), + const SizedBox(height: 12.0), + Text(message, textAlign: TextAlign.center), + ], + ), + floatingActionButton: floatingActionButton, + ); +} diff --git a/lib/fido/views/add_fingerprint_dialog.dart b/lib/fido/views/add_fingerprint_dialog.dart index 0ae210e7..d92f13a8 100755 --- a/lib/fido/views/add_fingerprint_dialog.dart +++ b/lib/fido/views/add_fingerprint_dialog.dart @@ -105,6 +105,14 @@ class _AddFingerprintDialogState extends ConsumerState } } + void _submit() async { + await ref + .read(fingerprintProvider(widget.devicePath).notifier) + .renameFingerprint(_fingerprint!, _label); + Navigator.of(context).pop(true); + showMessage(context, 'Fingerprint added'); + } + @override Widget build(BuildContext context) { // If current device changes, we need to pop back to the main Page. @@ -157,6 +165,9 @@ class _AddFingerprintDialogState extends ConsumerState _label = value.trim(); }); }, + onFieldSubmitted: (_) { + _submit(); + }, ), ] .map((e) => Padding( @@ -170,15 +181,7 @@ class _AddFingerprintDialogState extends ConsumerState }, actions: [ TextButton( - onPressed: _fingerprint != null && _label.isNotEmpty - ? () async { - await ref - .read(fingerprintProvider(widget.devicePath).notifier) - .renameFingerprint(_fingerprint!, _label); - Navigator.of(context).pop(true); - showMessage(context, 'Fingerprint added'); - } - : null, + onPressed: _fingerprint != null && _label.isNotEmpty ? _submit : null, child: const Text('Save'), ), ], diff --git a/lib/fido/views/fido_screen.dart b/lib/fido/views/fido_screen.dart index d2dc6d86..1fbbd5a6 100755 --- a/lib/fido/views/fido_screen.dart +++ b/lib/fido/views/fido_screen.dart @@ -9,6 +9,7 @@ import '../../app/views/app_failure_screen.dart'; import '../../app/views/app_loading_screen.dart'; import '../../app/views/app_page.dart'; import '../../app/views/device_avatar.dart'; +import '../../app/views/message_page.dart'; import '../../desktop/state.dart'; import '../../management/models.dart'; import '../state.dart'; @@ -31,11 +32,12 @@ class FidoScreen extends ConsumerWidget { final supported = deviceData .info.supportedCapabilities[deviceData.node.transport]!; if (Capability.fido2.value & supported == 0) { - return AppPage( - title: const Text('WebAuthn'), - centered: true, - child: const AppFailureScreen( - 'WebAuthn is supported by this device, but there are no management options available.')); + return const MessagePage( + title: Text('WebAuthn'), + header: 'No management options', + message: + 'WebAuthn is supported by this device, but there are no management options available.', + ); } if (Platform.isWindows) { if (!ref diff --git a/lib/fido/views/locked_page.dart b/lib/fido/views/locked_page.dart index ccc2cc08..90a159b3 100755 --- a/lib/fido/views/locked_page.dart +++ b/lib/fido/views/locked_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/views/app_page.dart'; +import '../../app/views/message_page.dart'; import '../models.dart'; import '../state.dart'; import 'pin_dialog.dart'; @@ -17,66 +18,80 @@ class FidoLockedPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + if (!state.hasPin) { + if (state.bioEnroll != null) { + return MessagePage( + title: const Text('WebAuthn'), + header: 'No fingerprints', + message: 'Set a PIN to register fingerprints', + floatingActionButton: _buildFab(context), + ); + } else { + return MessagePage( + title: const Text('WebAuthn'), + header: 'No discoverable accounts', + message: + 'Optionally set a PIN to protect access to your YubiKey\nRegister as a Security Key on websites', + floatingActionButton: _buildFab(context), + ); + } + } + return AppPage( title: const Text('WebAuthn'), child: Column( children: [ - if (state.bioEnroll == false) ...[ - const ListTile( - title: Text('Fingerprints'), - subtitle: Text('No fingerprints have been added'), - ), - ], - ...state.hasPin - ? [ - const ListTile(title: Text('Unlock')), - _PinEntryForm(node.path), - ] - : [ - const ListTile( - title: Text('PIN'), - subtitle: Text('No PIN has been set'), - ), - ], + const ListTile(title: Text('Unlock')), + _PinEntryForm(state, node), ], ), - floatingActionButton: FloatingActionButton.extended( - icon: const Icon(Icons.pin), - label: const Text('Setup'), - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - onPressed: () { - showBottomMenu(context, [ + ); + } + + FloatingActionButton _buildFab(BuildContext context) { + return FloatingActionButton.extended( + icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin), + label: const Text('Setup'), + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + onPressed: () { + showBottomMenu(context, [ + if (state.bioEnroll != null) MenuAction( - text: state.hasPin ? 'Change PIN' : 'Set PIN', - icon: const Icon(Icons.pin_outlined), - action: (context) { - showDialog( - context: context, - builder: (context) => FidoPinDialog(node.path, state), - ); - }, + text: 'Add fingerprint', + icon: const Icon(Icons.fingerprint), ), - MenuAction( - text: 'Delete all data', - icon: const Icon(Icons.delete_outline), - action: (context) { - showDialog( - context: context, - builder: (context) => ResetDialog(node), - ); - }, - ), - ]); - }, - ), + MenuAction( + text: 'Set PIN', + icon: const Icon(Icons.pin_outlined), + action: (context) { + showDialog( + context: context, + builder: (context) => FidoPinDialog(node.path, state), + ); + }, + ), + MenuAction( + text: 'Delete all data', + icon: const Icon(Icons.delete_outline), + action: (context) { + showDialog( + context: context, + builder: (context) => ResetDialog(node), + ); + }, + ), + ]); + }, ); } } class _PinEntryForm extends ConsumerStatefulWidget { - final DevicePath _devicePath; - const _PinEntryForm(this._devicePath, {Key? key}) : super(key: key); + final FidoState _state; + final DeviceNode _deviceNode; + const _PinEntryForm(this._state, this._deviceNode, {Key? key}) + : super(key: key); @override ConsumerState<_PinEntryForm> createState() => _PinEntryFormState(); @@ -89,7 +104,7 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> { void _submit() async { final result = await ref - .read(fidoStateProvider(widget._devicePath).notifier) + .read(fidoStateProvider(widget._deviceNode.path).notifier) .unlock(_pinController.text); result.whenOrNull(failed: (retries, authBlocked) { setState(() { @@ -115,12 +130,13 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> { @override Widget build(BuildContext context) { + final noFingerprints = widget._state.bioEnroll == false; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Enter the FIDO PIN for your YubiKey.'), + const Text('Enter the FIDO2 PIN for your YubiKey'), Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: TextField( @@ -136,9 +152,47 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> { onSubmitted: (_) => _submit(), ), ), - Container( - alignment: Alignment.centerRight, - child: ElevatedButton( + Wrap( + spacing: 4.0, + runSpacing: 8.0, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.pin), + label: const Text('Change PIN'), + onPressed: () { + showDialog( + context: context, + builder: (context) => + FidoPinDialog(widget._deviceNode.path, widget._state), + ); + }, + ), + OutlinedButton.icon( + icon: const Icon(Icons.delete), + label: const Text('Reset FIDO'), + onPressed: () { + showDialog( + context: context, + builder: (context) => ResetDialog(widget._deviceNode), + ); + }, + ), + ], + ), + const SizedBox(height: 16.0), + ListTile( + leading: + noFingerprints ? const Icon(Icons.warning_amber_rounded) : null, + title: noFingerprints + ? const Text( + 'No fingerprints have been added', + overflow: TextOverflow.fade, + ) + : null, + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 0), + minLeadingWidth: 0, + trailing: ElevatedButton( child: const Text('Unlock'), onPressed: _pinController.text.isNotEmpty && !_blocked ? _submit : null, diff --git a/lib/fido/views/pin_dialog.dart b/lib/fido/views/pin_dialog.dart index f55b19d7..08a6c953 100755 --- a/lib/fido/views/pin_dialog.dart +++ b/lib/fido/views/pin_dialog.dart @@ -32,8 +32,10 @@ class _FidoPinDialogState extends ConsumerState { Navigator.of(context).pop(); }); - final minPinLength = widget.state.minPinLength; final hasPin = widget.state.hasPin; + final isValid = _newPin.isNotEmpty && + _newPin == _confirmPin && + (!hasPin || _currentPin.isNotEmpty); return ResponsiveDialog( title: Text(hasPin ? 'Change PIN' : 'Set PIN'), @@ -71,7 +73,7 @@ class _FidoPinDialogState extends ConsumerState { obscureText: true, decoration: InputDecoration( border: const OutlineInputBorder(), - labelText: 'New password', + labelText: 'New PIN', enabled: !hasPin || _currentPin.isNotEmpty, errorText: _newPinError, ), @@ -86,7 +88,7 @@ class _FidoPinDialogState extends ConsumerState { obscureText: true, decoration: InputDecoration( border: const OutlineInputBorder(), - labelText: 'Confirm password', + labelText: 'Confirm PIN', enabled: _newPin.isNotEmpty, ), onChanged: (value) { @@ -94,6 +96,11 @@ class _FidoPinDialogState extends ConsumerState { _confirmPin = value; }); }, + onFieldSubmitted: (_) { + if (isValid) { + _submit(); + } + }, ), ] .map((e) => Padding( @@ -105,39 +112,36 @@ class _FidoPinDialogState extends ConsumerState { actions: [ TextButton( child: const Text('Save'), - onPressed: _newPin.isNotEmpty && - _newPin == _confirmPin && - (!hasPin || _currentPin.isNotEmpty) - ? () async { - final oldPin = _currentPin.isNotEmpty ? _currentPin : null; - if (_newPin.length < minPinLength) { - setState(() { - _newPinError = - 'New PIN must be at least $minPinLength characters'; - }); - return; - } - final result = await ref - .read(fidoStateProvider(widget.devicePath).notifier) - .setPin(_newPin, oldPin: oldPin); - result.when(success: () { - Navigator.of(context).pop(true); - showMessage(context, 'PIN set'); - }, failed: (retries, authBlocked) { - setState(() { - if (authBlocked) { - _currentPinError = - 'PIN has been blocked until the YubiKey is removed and reinserted'; - } else { - _currentPinError = - 'Wrong PIN ($retries tries remaining)'; - } - }); - }); - } - : null, + onPressed: isValid ? _submit : null, ), ], ); } + + void _submit() async { + final minPinLength = widget.state.minPinLength; + final oldPin = _currentPin.isNotEmpty ? _currentPin : null; + if (_newPin.length < minPinLength) { + setState(() { + _newPinError = 'New PIN must be at least $minPinLength characters'; + }); + return; + } + final result = await ref + .read(fidoStateProvider(widget.devicePath).notifier) + .setPin(_newPin, oldPin: oldPin); + result.when(success: () { + Navigator.of(context).pop(true); + showMessage(context, 'PIN set'); + }, failed: (retries, authBlocked) { + setState(() { + if (authBlocked) { + _currentPinError = + 'PIN has been blocked until the YubiKey is removed and reinserted'; + } else { + _currentPinError = 'Wrong PIN ($retries tries remaining)'; + } + }); + }); + } } diff --git a/lib/fido/views/unlocked_page.dart b/lib/fido/views/unlocked_page.dart index a668d2fe..42e2936a 100755 --- a/lib/fido/views/unlocked_page.dart +++ b/lib/fido/views/unlocked_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/views/app_page.dart'; +import '../../app/views/message_page.dart'; import '../models.dart'; import '../state.dart'; import 'add_fingerprint_dialog.dart'; @@ -21,16 +22,13 @@ class FidoUnlockedPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return AppPage( - title: const Text('WebAuthn'), - child: Column( - children: [ - if (state.credMgmt) ...[ - const ListTile(title: Text('Credentials')), - ...ref.watch(credentialProvider(node.path)).when( - data: (creds) => creds.isEmpty - ? [const Text('You have no stored credentials')] - : creds.map((cred) => ListTile( + List children = [ + if (state.credMgmt) + ...ref.watch(credentialProvider(node.path)).maybeWhen( + data: (creds) => creds.isNotEmpty + ? [ + const ListTile(title: Text('Credentials')), + ...creds.map((cred) => ListTile( leading: const CircleAvatar(child: Icon(Icons.link)), title: Text(cred.userName), @@ -51,18 +49,16 @@ class FidoUnlockedPage extends ConsumerWidget { ], ), )), - error: (err, trace) => - [const Text('Failed reading credentials')], - loading: () => - [const Center(child: CircularProgressIndicator())], - ), - ], - if (state.bioEnroll != null) ...[ - const ListTile(title: Text('Fingerprints')), - ...ref.watch(fingerprintProvider(node.path)).when( - data: (fingerprints) => fingerprints.isEmpty - ? [const Text('No fingerprints added')] - : fingerprints.map((fp) => ListTile( + ] + : [], + orElse: () => [], + ), + if (state.bioEnroll != null) + ...ref.watch(fingerprintProvider(node.path)).maybeWhen( + data: (fingerprints) => fingerprints.isNotEmpty + ? [ + const ListTile(title: Text('Fingerprints')), + ...fingerprints.map((fp) => ListTile( leading: const CircleAvatar( child: Icon(Icons.fingerprint)), title: Text(fp.label), @@ -91,56 +87,81 @@ class FidoUnlockedPage extends ConsumerWidget { icon: const Icon(Icons.delete)), ], ), - )), - error: (err, trace) => - [const Text('Failed reading fingerprints')], - loading: () => - [const Center(child: CircularProgressIndicator())], - ), - ], - ], - ), - floatingActionButton: FloatingActionButton.extended( - icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin), - label: const Text('Setup'), - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - onPressed: () { - showBottomMenu(context, [ - if (state.bioEnroll != null) - MenuAction( - text: 'Add fingerprint', - icon: const Icon(Icons.fingerprint), - action: (context) { - showDialog( - context: context, - builder: (context) => AddFingerprintDialog(node.path), - ); - }, - ), + )) + ] + : [], + orElse: () => [], + ), + ]; + + if (children.isNotEmpty) { + return AppPage( + title: const Text('WebAuthn'), + child: Column( + children: children, + ), + floatingActionButton: _buildFab(context), + ); + } + + if (state.bioEnroll == false) { + return MessagePage( + title: const Text('WebAuthn'), + header: 'No fingerprints', + message: 'Add one or more (up to five) fingerprints', + floatingActionButton: _buildFab(context), + ); + } + + return MessagePage( + title: const Text('WebAuthn'), + header: 'No discoverable accounts', + message: 'Register as a Security Key on websites', + floatingActionButton: _buildFab(context), + ); + } + + FloatingActionButton _buildFab(BuildContext context) { + return FloatingActionButton.extended( + icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin), + label: const Text('Setup'), + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + onPressed: () { + showBottomMenu(context, [ + if (state.bioEnroll != null) MenuAction( - text: 'Change PIN', - icon: const Icon(Icons.pin_outlined), + text: 'Add fingerprint', + icon: const Icon(Icons.fingerprint), action: (context) { showDialog( context: context, - builder: (context) => FidoPinDialog(node.path, state), + builder: (context) => AddFingerprintDialog(node.path), ); }, ), - MenuAction( - text: 'Delete all data', - icon: const Icon(Icons.delete_outline), - action: (context) { - showDialog( - context: context, - builder: (context) => ResetDialog(node), - ); - }, - ), - ]); - }, - ), + MenuAction( + text: 'Change PIN', + icon: const Icon(Icons.pin_outlined), + action: (context) { + showDialog( + context: context, + builder: (context) => FidoPinDialog(node.path, state), + ); + }, + ), + MenuAction( + text: 'Delete all data', + icon: const Icon(Icons.delete_outline), + action: (context) { + showDialog( + context: context, + builder: (context) => ResetDialog(node), + ); + }, + ), + ]); + }, ); } } diff --git a/lib/management/views/management_screen.dart b/lib/management/views/management_screen.dart index eac4cf9f..fc2d9f3e 100755 --- a/lib/management/views/management_screen.dart +++ b/lib/management/views/management_screen.dart @@ -28,8 +28,8 @@ class _CapabilityForm extends StatelessWidget { @override Widget build(BuildContext context) { return Wrap( - spacing: 6.0, - runSpacing: 6.0, + spacing: 4.0, + runSpacing: 8.0, children: Capability.values .where((c) => capabilities & c.value != 0) .map((c) => FilterChip( @@ -207,6 +207,7 @@ class _ManagementScreenState extends ConsumerState { .copyWith(enabledCapabilities: _enabled), reboot: reboot, ); + if (!reboot) Navigator.pop(context); showMessage(context, 'Configuration updated'); } finally { close?.call(); diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index 0395c1c2..c0776d1f 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -74,29 +74,49 @@ class AccountDialog extends ConsumerWidget with AccountMixin { Text(subtitle ?? ''), const SizedBox(height: 8.0), Center( - child: Chip( - avatar: calculateReady - ? Icon( - credential.touchRequired - ? Icons.touch_app - : Icons.refresh, - size: 36, - ) - : SizedBox.square( - dimension: 32, - child: CircleTimer( - code.validFrom * 1000, - code.validTo * 1000, - ), - ), - label: Text( - formatCode(code), - style: const TextStyle( - fontSize: 32.0, - fontFeatures: [FontFeature.tabularFigures()]), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(30.0)), + border: Border.all(width: 1.0, color: Colors.grey.shade500), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + calculateReady + ? Icon( + credential.touchRequired + ? Icons.touch_app + : Icons.refresh, + size: 36, + ) + : SizedBox.square( + dimension: 32, + child: CircleTimer( + code.validFrom * 1000, + code.validTo * 1000, + ), + ), + if (code != null) ...[ + const SizedBox(width: 8.0), + Opacity( + opacity: expired ? 0.4 : 1.0, + child: Text( + formatCode(code), + style: const TextStyle( + fontSize: 32.0, + fontFeatures: [FontFeature.tabularFigures()]), + ), + ) + ], + ], + ), ), ), - ) + ), ], ), actions: _buildActions(context, ref), diff --git a/lib/oath/views/account_mixin.dart b/lib/oath/views/account_mixin.dart index fe2b9e2f..9da885ef 100755 --- a/lib/oath/views/account_mixin.dart +++ b/lib/oath/views/account_mixin.dart @@ -74,7 +74,7 @@ mixin AccountMixin { String formatCode(OathCode? code) { final value = code?.value; if (value == null) { - return '••• •••'; + return ''; } else if (value.length < 6) { return value; } else { diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 930c0321..ba358b50 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -119,7 +119,12 @@ class AccountView extends ConsumerWidget with AccountMixin { style: const TextStyle(fontSize: 18), ), ), - title: Text(title), + title: Text( + title, + overflow: TextOverflow.fade, + maxLines: 1, + softWrap: false, + ), subtitle: subtitle != null ? Text( subtitle!, @@ -128,24 +133,45 @@ class AccountView extends ConsumerWidget with AccountMixin { softWrap: false, ) : null, - trailing: Chip( - avatar: calculateReady - ? Icon( - credential.touchRequired ? Icons.touch_app : Icons.refresh, - size: 18, - ) - : SizedBox.square( - dimension: 16, - child: CircleTimer( - code.validFrom * 1000, - code.validTo * 1000, + trailing: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(30.0)), + border: Border.all(width: 1.0, color: Colors.grey.shade500), + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + calculateReady + ? Icon( + credential.touchRequired + ? Icons.touch_app + : Icons.refresh, + size: 18, + ) + : SizedBox.square( + dimension: 16, + child: CircleTimer( + code.validFrom * 1000, + code.validTo * 1000, + ), + ), + if (code != null) const SizedBox(width: 8.0), + Opacity( + opacity: expired ? 0.4 : 1.0, + child: Text( + formatCode(code), + style: const TextStyle( + fontSize: 22.0, + fontFeatures: [FontFeature.tabularFigures()], + ), ), ), - label: Text( - formatCode(code), - style: const TextStyle( - fontSize: 22.0, - fontFeatures: [FontFeature.tabularFigures()], + ], ), ), ), diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index d2cf9456..506b36d5 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -40,6 +40,8 @@ class _OathAddAccountPageState extends ConsumerState { bool _validateSecretLength = false; _QrScanState _qrState = _QrScanState.none; bool _isObscure = true; + List _periodValues = [20, 30, 45, 60]; + List _digitsValues = [6, 8]; _scanQrCode(QrScanner qrScanner) async { try { @@ -48,17 +50,7 @@ class _OathAddAccountPageState extends ConsumerState { }); final otpauth = await qrScanner.scanQr(); final data = CredentialData.fromUri(Uri.parse(otpauth)); - setState(() { - _issuerController.text = data.issuer ?? ''; - _accountController.text = data.name; - _secretController.text = data.secret; - _oathType = data.oathType; - _hashAlgorithm = data.hashAlgorithm; - _periodController.text = '${data.period}'; - _digits = data.digits; - _isObscure = true; - _qrState = _QrScanState.success; - }); + _loadCredentialData(data); } catch (e) { setState(() { _qrState = _QrScanState.failed; @@ -66,6 +58,22 @@ class _OathAddAccountPageState extends ConsumerState { } } + _loadCredentialData(CredentialData data) { + setState(() { + _issuerController.text = data.issuer ?? ''; + _accountController.text = data.name; + _secretController.text = data.secret; + _oathType = data.oathType; + _hashAlgorithm = data.hashAlgorithm; + _periodValues = [data.period]; + _periodController.text = '${data.period}'; + _digitsValues = [data.digits]; + _digits = data.digits; + _isObscure = true; + _qrState = _QrScanState.success; + }); + } + List _buildQrStatus() { switch (_qrState) { case _QrScanState.success: @@ -123,16 +131,7 @@ class _OathAddAccountPageState extends ConsumerState { final b64Image = base64Encode(fileData); final otpauth = await qrScanner.scanQr(b64Image); final data = CredentialData.fromUri(Uri.parse(otpauth)); - setState(() { - _issuerController.text = data.issuer ?? ''; - _accountController.text = data.name; - _secretController.text = data.secret; - _oathType = data.oathType; - _hashAlgorithm = data.hashAlgorithm; - _periodController.text = '${data.period}'; - _digits = data.digits; - _qrState = _QrScanState.success; - }); + _loadCredentialData(data); } }, child: Column( @@ -289,7 +288,7 @@ class _OathAddAccountPageState extends ConsumerState { defaultPeriod, isDense: true, underline: null, - items: [20, 30, 45, 60] + items: _periodValues .map((e) => DropdownMenuItem( value: e, child: Text('$e sec'), @@ -312,7 +311,7 @@ class _OathAddAccountPageState extends ConsumerState { value: _digits, isDense: true, underline: null, - items: [6, 7, 8] + items: _digitsValues .map((e) => DropdownMenuItem( value: e, child: Text('$e digits'), diff --git a/lib/oath/views/manage_password_dialog.dart b/lib/oath/views/manage_password_dialog.dart index cb03373c..22f5459a 100755 --- a/lib/oath/views/manage_password_dialog.dart +++ b/lib/oath/views/manage_password_dialog.dart @@ -56,7 +56,8 @@ class _ManagePasswordDialogState extends ConsumerState { }, ), Wrap( - spacing: 8.0, + spacing: 4.0, + runSpacing: 8.0, children: [ OutlinedButton( child: const Text('Remove password'), diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 6722cc5f..b79a388f 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -7,6 +7,7 @@ import '../../app/models.dart'; import '../../app/views/app_failure_screen.dart'; import '../../app/views/app_loading_screen.dart'; import '../../app/views/app_page.dart'; +import '../../app/views/message_page.dart'; import '../models.dart'; import '../state.dart'; import 'account_list.dart'; @@ -53,41 +54,11 @@ class _LockedView extends ConsumerWidget { const ListTile(title: Text('Unlock')), _UnlockForm( devicePath, + oathState, keystore: oathState.keystore, ), ], ), - floatingActionButton: FloatingActionButton.extended( - icon: const Icon(Icons.password), - label: const Text('Setup'), - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - onPressed: () { - showBottomMenu(context, [ - MenuAction( - text: 'Change password', - icon: const Icon(Icons.password), - action: (context) { - showDialog( - context: context, - builder: (context) => - ManagePasswordDialog(devicePath, oathState), - ); - }, - ), - MenuAction( - text: 'Delete all data', - icon: const Icon(Icons.delete_outline), - action: (context) { - showDialog( - context: context, - builder: (context) => ResetDialog(devicePath), - ); - }, - ), - ]); - }, - ), ); } @@ -99,82 +70,101 @@ class _UnlockedView extends ConsumerWidget { : super(key: key); @override - Widget build(BuildContext context, WidgetRef ref) => AppPage( - title: Focus( - canRequestFocus: false, - onKeyEvent: (node, event) { - if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - node.focusInDirection(TraversalDirection.down); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Builder(builder: (context) { - return TextFormField( - initialValue: ref.read(searchProvider), - decoration: const InputDecoration( - hintText: 'Search accounts...', - border: InputBorder.none, - ), - onChanged: (value) { - ref.read(searchProvider.notifier).setFilter(value); - }, - textInputAction: TextInputAction.next, - onFieldSubmitted: (value) { - Focus.of(context).focusInDirection(TraversalDirection.down); - }, - ); - }), - ), - child: AccountList(devicePath, oathState), - floatingActionButton: FloatingActionButton.extended( - icon: const Icon(Icons.person_add_alt), - label: const Text('Setup'), - backgroundColor: Theme.of(context).colorScheme.secondary, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - onPressed: () { - showBottomMenu(context, [ - MenuAction( - text: 'Add account', - icon: const Icon(Icons.person_add_alt), - action: (context) { - showDialog( - context: context, - builder: (context) => OathAddAccountPage(devicePath), - ); - }, - ), - MenuAction( - text: oathState.hasKey ? 'Change password' : 'Set password', - icon: const Icon(Icons.password), - action: (context) { - showDialog( - context: context, - builder: (context) => - ManagePasswordDialog(devicePath, oathState), - ); - }, - ), - MenuAction( - text: 'Delete all data', - icon: const Icon(Icons.delete_outline), - action: (context) { - showDialog( - context: context, - builder: (context) => ResetDialog(devicePath), - ); - }, - ), - ]); - }, - ), + Widget build(BuildContext context, WidgetRef ref) { + final accounts = ref.watch(credentialListProvider(devicePath)); + if (accounts?.isEmpty ?? false) { + return MessagePage( + title: const Text('Authenticator'), + header: 'No accounts', + message: 'Follow the instructions on a website to add an account', + floatingActionButton: _buildFab(context), ); + } + + return AppPage( + title: Focus( + canRequestFocus: false, + onKeyEvent: (node, event) { + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + node.focusInDirection(TraversalDirection.down); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Builder(builder: (context) { + return TextFormField( + initialValue: ref.read(searchProvider), + decoration: const InputDecoration( + hintText: 'Search accounts', + border: InputBorder.none, + ), + onChanged: (value) { + ref.read(searchProvider.notifier).setFilter(value); + }, + textInputAction: TextInputAction.next, + onFieldSubmitted: (value) { + Focus.of(context).focusInDirection(TraversalDirection.down); + }, + ); + }), + ), + child: AccountList(devicePath, oathState), + floatingActionButton: _buildFab(context), + ); + } + + FloatingActionButton _buildFab(BuildContext context) { + final fab = FloatingActionButton.extended( + icon: const Icon(Icons.person_add_alt), + label: const Text('Setup'), + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + onPressed: () { + showBottomMenu(context, [ + MenuAction( + text: 'Add account', + icon: const Icon(Icons.person_add_alt), + action: (context) { + showDialog( + context: context, + builder: (context) => OathAddAccountPage(devicePath), + ); + }, + ), + MenuAction( + text: oathState.hasKey ? 'Manage password' : 'Set password', + icon: const Icon(Icons.password), + action: (context) { + showDialog( + context: context, + builder: (context) => + ManagePasswordDialog(devicePath, oathState), + ); + }, + ), + MenuAction( + text: 'Delete all data', + icon: const Icon(Icons.delete_outline), + action: (context) { + showDialog( + context: context, + builder: (context) => ResetDialog(devicePath), + ); + }, + ), + ]); + }, + ); + return fab; + } } class _UnlockForm extends ConsumerStatefulWidget { final DevicePath _devicePath; + final OathState _oathState; final KeystoreState keystore; - const _UnlockForm(this._devicePath, {Key? key, required this.keystore}) + const _UnlockForm(this._devicePath, this._oathState, + {Key? key, required this.keystore}) : super(key: key); @override @@ -212,9 +202,10 @@ class _UnlockFormState extends ConsumerState<_UnlockForm> { Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'Enter the password for your YubiKey. If you don\'t know your password, you\'ll need to reset the YubiKey.', + 'Enter the OATH password for your YubiKey', ), const SizedBox(height: 16.0), TextField( @@ -230,31 +221,68 @@ class _UnlockFormState extends ConsumerState<_UnlockForm> { onChanged: (_) => setState(() {}), // Update state on change onSubmitted: (_) => _submit(), ), + Wrap( + spacing: 4.0, + runSpacing: 8.0, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.password), + label: const Text('Manage password'), + onPressed: () { + showDialog( + context: context, + builder: (context) => ManagePasswordDialog( + widget._devicePath, widget._oathState), + ); + }, + ), + OutlinedButton.icon( + icon: const Icon(Icons.delete), + label: const Text('Reset OATH'), + onPressed: () { + showDialog( + context: context, + builder: (context) => ResetDialog(widget._devicePath), + ); + }, + ), + ], + ), ], ), ), - CheckboxListTile( - title: const Text('Remember password'), - subtitle: Text(keystoreFailed - ? 'The OS keychain is not available.' - : 'Uses the OS keychain to protect access to this YubiKey.'), - controlAffinity: ListTileControlAffinity.leading, - value: _remember, - onChanged: keystoreFailed - ? null - : (value) { - setState(() { - _remember = value ?? false; - }); - }, - ), - Container( - padding: const EdgeInsets.all(16.0), - alignment: Alignment.centerRight, - child: ElevatedButton( - child: const Text('Unlock'), - onPressed: _passwordController.text.isNotEmpty ? _submit : null, - ), + const SizedBox(height: 16.0), + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: keystoreFailed + ? const ListTile( + leading: Icon(Icons.warning_amber_rounded), + title: Text('OS Keystore unavailable'), + dense: true, + minLeadingWidth: 0, + ) + : CheckboxListTile( + title: const Text('Remember password'), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: _remember, + onChanged: (value) { + setState(() { + _remember = value ?? false; + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ElevatedButton( + child: const Text('Unlock'), + onPressed: _passwordController.text.isNotEmpty ? _submit : null, + ), + ) + ], ), ], ); diff --git a/lib/widgets/responsive_dialog.dart b/lib/widgets/responsive_dialog.dart index 9ca2d1e8..28823935 100755 --- a/lib/widgets/responsive_dialog.dart +++ b/lib/widgets/responsive_dialog.dart @@ -33,7 +33,7 @@ class _ResponsiveDialogState extends State { centerTitle: true, title: widget.title, actions: widget.actions, - leading: BackButton( + leading: CloseButton( onPressed: () { widget.onCancel?.call(); Navigator.of(context).pop();