This commit is contained in:
Dain Nilsson 2022-04-05 12:31:40 +02:00
commit c809bb9979
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
16 changed files with 551 additions and 357 deletions

View File

@ -47,6 +47,7 @@ class _BottomMenu extends ConsumerWidget {
.map((a) => ListTile( .map((a) => ListTile(
leading: a.icon, leading: a.icon,
title: Text(a.text), title: Text(a.text),
enabled: a.action != null,
onTap: a.action == null onTap: a.action == null
? null ? null
: () { : () {

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'message_page.dart';
import 'no_device_screen.dart'; import 'no_device_screen.dart';
import 'device_info_screen.dart'; import 'device_info_screen.dart';
import '../models.dart'; import '../models.dart';
@ -21,8 +22,9 @@ class MainPage extends ConsumerWidget {
} }
final app = ref.watch(currentAppProvider); final app = ref.watch(currentAppProvider);
if (app.getAvailability(deviceData) != Availability.enabled) { if (app.getAvailability(deviceData) != Availability.enabled) {
return const Center( return const MessagePage(
child: Text('This application is disabled'), header: 'Application disabled',
message: 'Enable the application on your YubiKey to access',
); );
} }

32
lib/app/views/message_page.dart Executable file
View File

@ -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,
);
}

View File

@ -105,6 +105,14 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
} }
} }
void _submit() async {
await ref
.read(fingerprintProvider(widget.devicePath).notifier)
.renameFingerprint(_fingerprint!, _label);
Navigator.of(context).pop(true);
showMessage(context, 'Fingerprint added');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// If current device changes, we need to pop back to the main Page. // If current device changes, we need to pop back to the main Page.
@ -157,6 +165,9 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
_label = value.trim(); _label = value.trim();
}); });
}, },
onFieldSubmitted: (_) {
_submit();
},
), ),
] ]
.map((e) => Padding( .map((e) => Padding(
@ -170,15 +181,7 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
}, },
actions: [ actions: [
TextButton( TextButton(
onPressed: _fingerprint != null && _label.isNotEmpty onPressed: _fingerprint != null && _label.isNotEmpty ? _submit : null,
? () async {
await ref
.read(fingerprintProvider(widget.devicePath).notifier)
.renameFingerprint(_fingerprint!, _label);
Navigator.of(context).pop(true);
showMessage(context, 'Fingerprint added');
}
: null,
child: const Text('Save'), child: const Text('Save'),
), ),
], ],

View File

@ -9,6 +9,7 @@ import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_loading_screen.dart'; import '../../app/views/app_loading_screen.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/device_avatar.dart'; import '../../app/views/device_avatar.dart';
import '../../app/views/message_page.dart';
import '../../desktop/state.dart'; import '../../desktop/state.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../state.dart'; import '../state.dart';
@ -31,11 +32,12 @@ class FidoScreen extends ConsumerWidget {
final supported = deviceData final supported = deviceData
.info.supportedCapabilities[deviceData.node.transport]!; .info.supportedCapabilities[deviceData.node.transport]!;
if (Capability.fido2.value & supported == 0) { if (Capability.fido2.value & supported == 0) {
return AppPage( return const MessagePage(
title: const Text('WebAuthn'), title: Text('WebAuthn'),
centered: true, header: 'No management options',
child: const AppFailureScreen( message:
'WebAuthn is supported by this device, but there are no management options available.')); 'WebAuthn is supported by this device, but there are no management options available.',
);
} }
if (Platform.isWindows) { if (Platform.isWindows) {
if (!ref if (!ref

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'pin_dialog.dart'; import 'pin_dialog.dart';
@ -17,66 +18,80 @@ class FidoLockedPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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( return AppPage(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
child: Column( child: Column(
children: [ children: [
if (state.bioEnroll == false) ...[ const ListTile(title: Text('Unlock')),
const ListTile( _PinEntryForm(state, node),
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'),
),
],
], ],
), ),
floatingActionButton: FloatingActionButton.extended( );
icon: const Icon(Icons.pin), }
label: const Text('Setup'),
backgroundColor: Theme.of(context).colorScheme.secondary, FloatingActionButton _buildFab(BuildContext context) {
foregroundColor: Theme.of(context).colorScheme.onSecondary, return FloatingActionButton.extended(
onPressed: () { icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin),
showBottomMenu(context, [ label: const Text('Setup'),
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
onPressed: () {
showBottomMenu(context, [
if (state.bioEnroll != null)
MenuAction( MenuAction(
text: state.hasPin ? 'Change PIN' : 'Set PIN', text: 'Add fingerprint',
icon: const Icon(Icons.pin_outlined), icon: const Icon(Icons.fingerprint),
action: (context) {
showDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
), ),
MenuAction( MenuAction(
text: 'Delete all data', text: 'Set PIN',
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.pin_outlined),
action: (context) { action: (context) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => ResetDialog(node), 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 { class _PinEntryForm extends ConsumerStatefulWidget {
final DevicePath _devicePath; final FidoState _state;
const _PinEntryForm(this._devicePath, {Key? key}) : super(key: key); final DeviceNode _deviceNode;
const _PinEntryForm(this._state, this._deviceNode, {Key? key})
: super(key: key);
@override @override
ConsumerState<_PinEntryForm> createState() => _PinEntryFormState(); ConsumerState<_PinEntryForm> createState() => _PinEntryFormState();
@ -89,7 +104,7 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
void _submit() async { void _submit() async {
final result = await ref final result = await ref
.read(fidoStateProvider(widget._devicePath).notifier) .read(fidoStateProvider(widget._deviceNode.path).notifier)
.unlock(_pinController.text); .unlock(_pinController.text);
result.whenOrNull(failed: (retries, authBlocked) { result.whenOrNull(failed: (retries, authBlocked) {
setState(() { setState(() {
@ -115,12 +130,13 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final noFingerprints = widget._state.bioEnroll == false;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Enter the FIDO PIN for your YubiKey.'), const Text('Enter the FIDO2 PIN for your YubiKey'),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0), padding: const EdgeInsets.symmetric(vertical: 16.0),
child: TextField( child: TextField(
@ -136,9 +152,47 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
onSubmitted: (_) => _submit(), onSubmitted: (_) => _submit(),
), ),
), ),
Container( Wrap(
alignment: Alignment.centerRight, spacing: 4.0,
child: ElevatedButton( 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'), child: const Text('Unlock'),
onPressed: onPressed:
_pinController.text.isNotEmpty && !_blocked ? _submit : null, _pinController.text.isNotEmpty && !_blocked ? _submit : null,

View File

@ -32,8 +32,10 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
Navigator.of(context).pop(); Navigator.of(context).pop();
}); });
final minPinLength = widget.state.minPinLength;
final hasPin = widget.state.hasPin; final hasPin = widget.state.hasPin;
final isValid = _newPin.isNotEmpty &&
_newPin == _confirmPin &&
(!hasPin || _currentPin.isNotEmpty);
return ResponsiveDialog( return ResponsiveDialog(
title: Text(hasPin ? 'Change PIN' : 'Set PIN'), title: Text(hasPin ? 'Change PIN' : 'Set PIN'),
@ -71,7 +73,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: 'New password', labelText: 'New PIN',
enabled: !hasPin || _currentPin.isNotEmpty, enabled: !hasPin || _currentPin.isNotEmpty,
errorText: _newPinError, errorText: _newPinError,
), ),
@ -86,7 +88,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: 'Confirm password', labelText: 'Confirm PIN',
enabled: _newPin.isNotEmpty, enabled: _newPin.isNotEmpty,
), ),
onChanged: (value) { onChanged: (value) {
@ -94,6 +96,11 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
_confirmPin = value; _confirmPin = value;
}); });
}, },
onFieldSubmitted: (_) {
if (isValid) {
_submit();
}
},
), ),
] ]
.map((e) => Padding( .map((e) => Padding(
@ -105,39 +112,36 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
actions: [ actions: [
TextButton( TextButton(
child: const Text('Save'), child: const Text('Save'),
onPressed: _newPin.isNotEmpty && onPressed: isValid ? _submit : null,
_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,
), ),
], ],
); );
} }
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)';
}
});
});
}
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'add_fingerprint_dialog.dart'; import 'add_fingerprint_dialog.dart';
@ -21,16 +22,13 @@ class FidoUnlockedPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return AppPage( List<Widget> children = [
title: const Text('WebAuthn'), if (state.credMgmt)
child: Column( ...ref.watch(credentialProvider(node.path)).maybeWhen(
children: [ data: (creds) => creds.isNotEmpty
if (state.credMgmt) ...[ ? [
const ListTile(title: Text('Credentials')), const ListTile(title: Text('Credentials')),
...ref.watch(credentialProvider(node.path)).when( ...creds.map((cred) => ListTile(
data: (creds) => creds.isEmpty
? [const Text('You have no stored credentials')]
: creds.map((cred) => ListTile(
leading: leading:
const CircleAvatar(child: Icon(Icons.link)), const CircleAvatar(child: Icon(Icons.link)),
title: Text(cred.userName), title: Text(cred.userName),
@ -51,18 +49,16 @@ class FidoUnlockedPage extends ConsumerWidget {
], ],
), ),
)), )),
error: (err, trace) => ]
[const Text('Failed reading credentials')], : [],
loading: () => orElse: () => [],
[const Center(child: CircularProgressIndicator())], ),
), if (state.bioEnroll != null)
], ...ref.watch(fingerprintProvider(node.path)).maybeWhen(
if (state.bioEnroll != null) ...[ data: (fingerprints) => fingerprints.isNotEmpty
const ListTile(title: Text('Fingerprints')), ? [
...ref.watch(fingerprintProvider(node.path)).when( const ListTile(title: Text('Fingerprints')),
data: (fingerprints) => fingerprints.isEmpty ...fingerprints.map((fp) => ListTile(
? [const Text('No fingerprints added')]
: fingerprints.map((fp) => ListTile(
leading: const CircleAvatar( leading: const CircleAvatar(
child: Icon(Icons.fingerprint)), child: Icon(Icons.fingerprint)),
title: Text(fp.label), title: Text(fp.label),
@ -91,56 +87,81 @@ class FidoUnlockedPage extends ConsumerWidget {
icon: const Icon(Icons.delete)), icon: const Icon(Icons.delete)),
], ],
), ),
)), ))
error: (err, trace) => ]
[const Text('Failed reading fingerprints')], : [],
loading: () => orElse: () => [],
[const Center(child: CircularProgressIndicator())], ),
), ];
],
], if (children.isNotEmpty) {
), return AppPage(
floatingActionButton: FloatingActionButton.extended( title: const Text('WebAuthn'),
icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin), child: Column(
label: const Text('Setup'), children: children,
backgroundColor: Theme.of(context).colorScheme.secondary, ),
foregroundColor: Theme.of(context).colorScheme.onSecondary, floatingActionButton: _buildFab(context),
onPressed: () { );
showBottomMenu(context, [ }
if (state.bioEnroll != null)
MenuAction( if (state.bioEnroll == false) {
text: 'Add fingerprint', return MessagePage(
icon: const Icon(Icons.fingerprint), title: const Text('WebAuthn'),
action: (context) { header: 'No fingerprints',
showDialog( message: 'Add one or more (up to five) fingerprints',
context: context, floatingActionButton: _buildFab(context),
builder: (context) => AddFingerprintDialog(node.path), );
); }
},
), 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( MenuAction(
text: 'Change PIN', text: 'Add fingerprint',
icon: const Icon(Icons.pin_outlined), icon: const Icon(Icons.fingerprint),
action: (context) { action: (context) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => FidoPinDialog(node.path, state), builder: (context) => AddFingerprintDialog(node.path),
); );
}, },
), ),
MenuAction( MenuAction(
text: 'Delete all data', text: 'Change PIN',
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.pin_outlined),
action: (context) { action: (context) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => ResetDialog(node), 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),
);
},
),
]);
},
); );
} }
} }

View File

@ -28,8 +28,8 @@ class _CapabilityForm extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Wrap( return Wrap(
spacing: 6.0, spacing: 4.0,
runSpacing: 6.0, runSpacing: 8.0,
children: Capability.values children: Capability.values
.where((c) => capabilities & c.value != 0) .where((c) => capabilities & c.value != 0)
.map((c) => FilterChip( .map((c) => FilterChip(
@ -207,6 +207,7 @@ class _ManagementScreenState extends ConsumerState<ManagementScreen> {
.copyWith(enabledCapabilities: _enabled), .copyWith(enabledCapabilities: _enabled),
reboot: reboot, reboot: reboot,
); );
if (!reboot) Navigator.pop(context);
showMessage(context, 'Configuration updated'); showMessage(context, 'Configuration updated');
} finally { } finally {
close?.call(); close?.call();

View File

@ -74,29 +74,49 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
Text(subtitle ?? ''), Text(subtitle ?? ''),
const SizedBox(height: 8.0), const SizedBox(height: 8.0),
Center( Center(
child: Chip( child: DecoratedBox(
avatar: calculateReady decoration: BoxDecoration(
? Icon( shape: BoxShape.rectangle,
credential.touchRequired borderRadius: const BorderRadius.all(Radius.circular(30.0)),
? Icons.touch_app border: Border.all(width: 1.0, color: Colors.grey.shade500),
: Icons.refresh, ),
size: 36, child: Padding(
) padding: const EdgeInsets.symmetric(
: SizedBox.square( horizontal: 16.0, vertical: 8.0),
dimension: 32, child: Row(
child: CircleTimer( mainAxisSize: MainAxisSize.min,
code.validFrom * 1000, children: [
code.validTo * 1000, calculateReady
), ? Icon(
), credential.touchRequired
label: Text( ? Icons.touch_app
formatCode(code), : Icons.refresh,
style: const TextStyle( size: 36,
fontSize: 32.0, )
fontFeatures: [FontFeature.tabularFigures()]), : 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), actions: _buildActions(context, ref),

View File

@ -74,7 +74,7 @@ mixin AccountMixin {
String formatCode(OathCode? code) { String formatCode(OathCode? code) {
final value = code?.value; final value = code?.value;
if (value == null) { if (value == null) {
return '••• •••'; return '';
} else if (value.length < 6) { } else if (value.length < 6) {
return value; return value;
} else { } else {

View File

@ -119,7 +119,12 @@ class AccountView extends ConsumerWidget with AccountMixin {
style: const TextStyle(fontSize: 18), style: const TextStyle(fontSize: 18),
), ),
), ),
title: Text(title), title: Text(
title,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
),
subtitle: subtitle != null subtitle: subtitle != null
? Text( ? Text(
subtitle!, subtitle!,
@ -128,24 +133,45 @@ class AccountView extends ConsumerWidget with AccountMixin {
softWrap: false, softWrap: false,
) )
: null, : null,
trailing: Chip( trailing: DecoratedBox(
avatar: calculateReady decoration: BoxDecoration(
? Icon( shape: BoxShape.rectangle,
credential.touchRequired ? Icons.touch_app : Icons.refresh, borderRadius: const BorderRadius.all(Radius.circular(30.0)),
size: 18, border: Border.all(width: 1.0, color: Colors.grey.shade500),
) ),
: SizedBox.square( child: Padding(
dimension: 16, padding:
child: CircleTimer( const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0),
code.validFrom * 1000, child: Row(
code.validTo * 1000, 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()],
), ),
), ),
), ),

View File

@ -40,6 +40,8 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
bool _validateSecretLength = false; bool _validateSecretLength = false;
_QrScanState _qrState = _QrScanState.none; _QrScanState _qrState = _QrScanState.none;
bool _isObscure = true; bool _isObscure = true;
List<int> _periodValues = [20, 30, 45, 60];
List<int> _digitsValues = [6, 8];
_scanQrCode(QrScanner qrScanner) async { _scanQrCode(QrScanner qrScanner) async {
try { try {
@ -48,17 +50,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
}); });
final otpauth = await qrScanner.scanQr(); final otpauth = await qrScanner.scanQr();
final data = CredentialData.fromUri(Uri.parse(otpauth)); final data = CredentialData.fromUri(Uri.parse(otpauth));
setState(() { _loadCredentialData(data);
_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;
});
} catch (e) { } catch (e) {
setState(() { setState(() {
_qrState = _QrScanState.failed; _qrState = _QrScanState.failed;
@ -66,6 +58,22 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
} }
} }
_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<Widget> _buildQrStatus() { List<Widget> _buildQrStatus() {
switch (_qrState) { switch (_qrState) {
case _QrScanState.success: case _QrScanState.success:
@ -123,16 +131,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
final b64Image = base64Encode(fileData); final b64Image = base64Encode(fileData);
final otpauth = await qrScanner.scanQr(b64Image); final otpauth = await qrScanner.scanQr(b64Image);
final data = CredentialData.fromUri(Uri.parse(otpauth)); final data = CredentialData.fromUri(Uri.parse(otpauth));
setState(() { _loadCredentialData(data);
_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;
});
} }
}, },
child: Column( child: Column(
@ -289,7 +288,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
defaultPeriod, defaultPeriod,
isDense: true, isDense: true,
underline: null, underline: null,
items: [20, 30, 45, 60] items: _periodValues
.map((e) => DropdownMenuItem( .map((e) => DropdownMenuItem(
value: e, value: e,
child: Text('$e sec'), child: Text('$e sec'),
@ -312,7 +311,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
value: _digits, value: _digits,
isDense: true, isDense: true,
underline: null, underline: null,
items: [6, 7, 8] items: _digitsValues
.map((e) => DropdownMenuItem( .map((e) => DropdownMenuItem(
value: e, value: e,
child: Text('$e digits'), child: Text('$e digits'),

View File

@ -56,7 +56,8 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
}, },
), ),
Wrap( Wrap(
spacing: 8.0, spacing: 4.0,
runSpacing: 8.0,
children: [ children: [
OutlinedButton( OutlinedButton(
child: const Text('Remove password'), child: const Text('Remove password'),

View File

@ -7,6 +7,7 @@ import '../../app/models.dart';
import '../../app/views/app_failure_screen.dart'; import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_loading_screen.dart'; import '../../app/views/app_loading_screen.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'account_list.dart'; import 'account_list.dart';
@ -53,41 +54,11 @@ class _LockedView extends ConsumerWidget {
const ListTile(title: Text('Unlock')), const ListTile(title: Text('Unlock')),
_UnlockForm( _UnlockForm(
devicePath, devicePath,
oathState,
keystore: oathState.keystore, 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); : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) => AppPage( Widget build(BuildContext context, WidgetRef ref) {
title: Focus( final accounts = ref.watch(credentialListProvider(devicePath));
canRequestFocus: false, if (accounts?.isEmpty ?? false) {
onKeyEvent: (node, event) { return MessagePage(
if (event.logicalKey == LogicalKeyboardKey.arrowDown) { title: const Text('Authenticator'),
node.focusInDirection(TraversalDirection.down); header: 'No accounts',
return KeyEventResult.handled; message: 'Follow the instructions on a website to add an account',
} floatingActionButton: _buildFab(context),
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),
);
},
),
]);
},
),
); );
}
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 { class _UnlockForm extends ConsumerStatefulWidget {
final DevicePath _devicePath; final DevicePath _devicePath;
final OathState _oathState;
final KeystoreState keystore; 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); : super(key: key);
@override @override
@ -212,9 +202,10 @@ class _UnlockFormState extends ConsumerState<_UnlockForm> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( 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), const SizedBox(height: 16.0),
TextField( TextField(
@ -230,31 +221,68 @@ class _UnlockFormState extends ConsumerState<_UnlockForm> {
onChanged: (_) => setState(() {}), // Update state on change onChanged: (_) => setState(() {}), // Update state on change
onSubmitted: (_) => _submit(), 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( const SizedBox(height: 16.0),
title: const Text('Remember password'), Row(
subtitle: Text(keystoreFailed mainAxisSize: MainAxisSize.max,
? 'The OS keychain is not available.' children: [
: 'Uses the OS keychain to protect access to this YubiKey.'), Expanded(
controlAffinity: ListTileControlAffinity.leading, child: keystoreFailed
value: _remember, ? const ListTile(
onChanged: keystoreFailed leading: Icon(Icons.warning_amber_rounded),
? null title: Text('OS Keystore unavailable'),
: (value) { dense: true,
setState(() { minLeadingWidth: 0,
_remember = value ?? false; )
}); : CheckboxListTile(
}, title: const Text('Remember password'),
), dense: true,
Container( controlAffinity: ListTileControlAffinity.leading,
padding: const EdgeInsets.all(16.0), value: _remember,
alignment: Alignment.centerRight, onChanged: (value) {
child: ElevatedButton( setState(() {
child: const Text('Unlock'), _remember = value ?? false;
onPressed: _passwordController.text.isNotEmpty ? _submit : null, });
), },
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton(
child: const Text('Unlock'),
onPressed: _passwordController.text.isNotEmpty ? _submit : null,
),
)
],
), ),
], ],
); );

View File

@ -33,7 +33,7 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
centerTitle: true, centerTitle: true,
title: widget.title, title: widget.title,
actions: widget.actions, actions: widget.actions,
leading: BackButton( leading: CloseButton(
onPressed: () { onPressed: () {
widget.onCancel?.call(); widget.onCancel?.call();
Navigator.of(context).pop(); Navigator.of(context).pop();