mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-25 19:21:38 +03:00
Merge PR #85.
This commit is contained in:
commit
c809bb9979
@ -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
|
||||||
: () {
|
: () {
|
||||||
|
@ -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
32
lib/app/views/message_page.dart
Executable 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,
|
||||||
|
);
|
||||||
|
}
|
@ -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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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),
|
||||||
|
@ -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 {
|
||||||
|
@ -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()],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -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'),
|
||||||
|
@ -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'),
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
@ -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();
|
||||||
|
Loading…
Reference in New Issue
Block a user