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(
|
||||
leading: a.icon,
|
||||
title: Text(a.text),
|
||||
enabled: a.action != null,
|
||||
onTap: a.action == null
|
||||
? null
|
||||
: () {
|
||||
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
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
|
||||
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<AddFingerprintDialog>
|
||||
_label = value.trim();
|
||||
});
|
||||
},
|
||||
onFieldSubmitted: (_) {
|
||||
_submit();
|
||||
},
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
@ -170,15 +181,7 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
|
||||
},
|
||||
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'),
|
||||
),
|
||||
],
|
||||
|
@ -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
|
||||
|
@ -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,38 +18,51 @@ 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'),
|
||||
),
|
||||
],
|
||||
_PinEntryForm(state, node),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.pin),
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
text: 'Add fingerprint',
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
),
|
||||
MenuAction(
|
||||
text: 'Set PIN',
|
||||
icon: const Icon(Icons.pin_outlined),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
@ -69,14 +83,15 @@ class FidoLockedPage extends ConsumerWidget {
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
@ -32,8 +32,10 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
|
||||
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<FidoPinDialog> {
|
||||
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<FidoPinDialog> {
|
||||
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<FidoPinDialog> {
|
||||
_confirmPin = value;
|
||||
});
|
||||
},
|
||||
onFieldSubmitted: (_) {
|
||||
if (isValid) {
|
||||
_submit();
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
@ -105,15 +112,18 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Save'),
|
||||
onPressed: _newPin.isNotEmpty &&
|
||||
_newPin == _confirmPin &&
|
||||
(!hasPin || _currentPin.isNotEmpty)
|
||||
? () async {
|
||||
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';
|
||||
_newPinError = 'New PIN must be at least $minPinLength characters';
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -129,15 +139,9 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
|
||||
_currentPinError =
|
||||
'PIN has been blocked until the YubiKey is removed and reinserted';
|
||||
} else {
|
||||
_currentPinError =
|
||||
'Wrong PIN ($retries tries remaining)';
|
||||
_currentPinError = 'Wrong PIN ($retries tries remaining)';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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) ...[
|
||||
List<Widget> children = [
|
||||
if (state.credMgmt)
|
||||
...ref.watch(credentialProvider(node.path)).maybeWhen(
|
||||
data: (creds) => creds.isNotEmpty
|
||||
? [
|
||||
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(
|
||||
...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())],
|
||||
]
|
||||
: [],
|
||||
orElse: () => [],
|
||||
),
|
||||
],
|
||||
if (state.bioEnroll != null) ...[
|
||||
if (state.bioEnroll != null)
|
||||
...ref.watch(fingerprintProvider(node.path)).maybeWhen(
|
||||
data: (fingerprints) => fingerprints.isNotEmpty
|
||||
? [
|
||||
const ListTile(title: Text('Fingerprints')),
|
||||
...ref.watch(fingerprintProvider(node.path)).when(
|
||||
data: (fingerprints) => fingerprints.isEmpty
|
||||
? [const Text('No fingerprints added')]
|
||||
: fingerprints.map((fp) => ListTile(
|
||||
...fingerprints.map((fp) => ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.fingerprint)),
|
||||
title: Text(fp.label),
|
||||
@ -91,16 +87,42 @@ class FidoUnlockedPage extends ConsumerWidget {
|
||||
icon: const Icon(Icons.delete)),
|
||||
],
|
||||
),
|
||||
)),
|
||||
error: (err, trace) =>
|
||||
[const Text('Failed reading fingerprints')],
|
||||
loading: () =>
|
||||
[const Center(child: CircularProgressIndicator())],
|
||||
))
|
||||
]
|
||||
: [],
|
||||
orElse: () => [],
|
||||
),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (children.isNotEmpty) {
|
||||
return AppPage(
|
||||
title: const Text('WebAuthn'),
|
||||
child: Column(
|
||||
children: children,
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
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,
|
||||
@ -140,7 +162,6 @@ class FidoUnlockedPage extends ConsumerWidget {
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<ManagementScreen> {
|
||||
.copyWith(enabledCapabilities: _enabled),
|
||||
reboot: reboot,
|
||||
);
|
||||
if (!reboot) Navigator.pop(context);
|
||||
showMessage(context, 'Configuration updated');
|
||||
} finally {
|
||||
close?.call();
|
||||
|
@ -74,8 +74,19 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
|
||||
Text(subtitle ?? ''),
|
||||
const SizedBox(height: 8.0),
|
||||
Center(
|
||||
child: Chip(
|
||||
avatar: calculateReady
|
||||
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
|
||||
@ -89,15 +100,24 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
|
||||
code.validTo * 1000,
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
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),
|
||||
),
|
||||
|
@ -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 {
|
||||
|
@ -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,10 +133,24 @@ class AccountView extends ConsumerWidget with AccountMixin {
|
||||
softWrap: false,
|
||||
)
|
||||
: null,
|
||||
trailing: Chip(
|
||||
avatar: calculateReady
|
||||
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,
|
||||
credential.touchRequired
|
||||
? Icons.touch_app
|
||||
: Icons.refresh,
|
||||
size: 18,
|
||||
)
|
||||
: SizedBox.square(
|
||||
@ -141,7 +160,10 @@ class AccountView extends ConsumerWidget with AccountMixin {
|
||||
code.validTo * 1000,
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
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,
|
||||
@ -149,6 +171,10 @@ class AccountView extends ConsumerWidget with AccountMixin {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -40,6 +40,8 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
bool _validateSecretLength = false;
|
||||
_QrScanState _qrState = _QrScanState.none;
|
||||
bool _isObscure = true;
|
||||
List<int> _periodValues = [20, 30, 45, 60];
|
||||
List<int> _digitsValues = [6, 8];
|
||||
|
||||
_scanQrCode(QrScanner qrScanner) async {
|
||||
try {
|
||||
@ -48,22 +50,28 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
});
|
||||
final otpauth = await qrScanner.scanQr();
|
||||
final data = CredentialData.fromUri(Uri.parse(otpauth));
|
||||
_loadCredentialData(data);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_qrState = _QrScanState.failed;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_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;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_qrState = _QrScanState.failed;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildQrStatus() {
|
||||
@ -123,16 +131,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
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<OathAddAccountPage> {
|
||||
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<OathAddAccountPage> {
|
||||
value: _digits,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: [6, 7, 8]
|
||||
items: _digitsValues
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text('$e digits'),
|
||||
|
@ -56,7 +56,8 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
|
||||
},
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
child: const Text('Remove password'),
|
||||
|
@ -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,7 +70,18 @@ class _UnlockedView extends ConsumerWidget {
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => AppPage(
|
||||
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) {
|
||||
@ -113,7 +95,7 @@ class _UnlockedView extends ConsumerWidget {
|
||||
return TextFormField(
|
||||
initialValue: ref.read(searchProvider),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search accounts...',
|
||||
hintText: 'Search accounts',
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (value) {
|
||||
@ -127,7 +109,12 @@ class _UnlockedView extends ConsumerWidget {
|
||||
}),
|
||||
),
|
||||
child: AccountList(devicePath, oathState),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
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,
|
||||
@ -145,7 +132,7 @@ class _UnlockedView extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
text: oathState.hasKey ? 'Change password' : 'Set password',
|
||||
text: oathState.hasKey ? 'Manage password' : 'Set password',
|
||||
icon: const Icon(Icons.password),
|
||||
action: (context) {
|
||||
showDialog(
|
||||
@ -167,14 +154,17 @@ class _UnlockedView extends ConsumerWidget {
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
);
|
||||
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(
|
||||
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'),
|
||||
subtitle: Text(keystoreFailed
|
||||
? 'The OS keychain is not available.'
|
||||
: 'Uses the OS keychain to protect access to this YubiKey.'),
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: _remember,
|
||||
onChanged: keystoreFailed
|
||||
? null
|
||||
: (value) {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_remember = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
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,
|
||||
title: widget.title,
|
||||
actions: widget.actions,
|
||||
leading: BackButton(
|
||||
leading: CloseButton(
|
||||
onPressed: () {
|
||||
widget.onCancel?.call();
|
||||
Navigator.of(context).pop();
|
||||
|
Loading…
Reference in New Issue
Block a user