mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-24 02:33:44 +03:00
Merge branch 'main' into feature/android-native
This commit is contained in:
commit
a9f7a2eef6
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'app/views/responsive_dialog.dart';
|
||||
import 'core/state.dart';
|
||||
import 'desktop/state.dart';
|
||||
|
||||
@ -14,44 +15,36 @@ class AboutPage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('About Yubico Authenticator'),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// TODO: Store the version number elsewhere
|
||||
const Text('Yubico Authenticator version: 6.0.0-alpha.1'),
|
||||
if (isDesktop)
|
||||
Text('ykman version: ${ref.watch(rpcStateProvider).version}'),
|
||||
Text('Dart version: ${Platform.version}'),
|
||||
const SizedBox(height: 8.0),
|
||||
const Divider(),
|
||||
if (isDesktop)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
_log.info('Running diagnostics...');
|
||||
final response =
|
||||
await ref.read(rpcProvider).command('diagnose', []);
|
||||
_log.info('Response', response['diagnostics']);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Diagnostics done. See log for results...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Run diagnostics...'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return ResponsiveDialog(
|
||||
title: const Text('About Yubico Authenticator'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// TODO: Store the version number elsewhere
|
||||
const Text('Yubico Authenticator version: 6.0.0-alpha.1'),
|
||||
if (isDesktop)
|
||||
Text('ykman version: ${ref.watch(rpcStateProvider).version}'),
|
||||
Text('Dart version: ${Platform.version}'),
|
||||
const SizedBox(height: 8.0),
|
||||
const Divider(),
|
||||
if (isDesktop)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
_log.info('Running diagnostics...');
|
||||
final response =
|
||||
await ref.read(rpcProvider).command('diagnose', []);
|
||||
_log.info('Response', response['diagnostics']);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Diagnostics done. See log for results...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Run diagnostics...'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../management/views/management_screen.dart';
|
||||
import '../../about_page.dart';
|
||||
import '../../settings_page.dart';
|
||||
import '../models.dart';
|
||||
@ -78,12 +79,12 @@ class MainPageDrawer extends ConsumerWidget {
|
||||
DrawerItem(
|
||||
titleText: 'Toggle applications',
|
||||
icon: Icon(Application.management._icon),
|
||||
selected: Application.management == currentApp,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(currentAppProvider.notifier)
|
||||
.setCurrentApp(Application.management);
|
||||
if (shouldPop) Navigator.of(context).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ManagementScreen(data),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
@ -103,9 +104,8 @@ class MainPageDrawer extends ConsumerWidget {
|
||||
onTap: () {
|
||||
final nav = Navigator.of(context);
|
||||
if (shouldPop) nav.pop();
|
||||
nav.push(
|
||||
MaterialPageRoute(builder: (context) => const SettingsPage()),
|
||||
);
|
||||
showDialog(
|
||||
context: context, builder: (context) => const SettingsPage());
|
||||
},
|
||||
),
|
||||
DrawerItem(
|
||||
@ -114,9 +114,8 @@ class MainPageDrawer extends ConsumerWidget {
|
||||
onTap: () {
|
||||
final nav = Navigator.of(context);
|
||||
if (shouldPop) nav.pop();
|
||||
nav.push(
|
||||
MaterialPageRoute(builder: (context) => const AboutPage()),
|
||||
);
|
||||
showDialog(
|
||||
context: context, builder: (context) => const AboutPage());
|
||||
},
|
||||
),
|
||||
],
|
||||
|
@ -36,26 +36,27 @@ class MainPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final query = MediaQuery.of(context);
|
||||
if (query.size.width < 540) {
|
||||
// Single column layout
|
||||
return _buildScaffold(context, ref, true);
|
||||
} else {
|
||||
// Two-column layout
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 240,
|
||||
child: MainPageDrawer(shouldPop: false),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildScaffold(context, ref, false),
|
||||
),
|
||||
],
|
||||
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 540) {
|
||||
// Single column layout
|
||||
return _buildScaffold(context, ref, true);
|
||||
} else {
|
||||
// Two-column layout
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 240,
|
||||
child: MainPageDrawer(shouldPop: false),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildScaffold(context, ref, false),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold _buildScaffold(BuildContext context, WidgetRef ref, bool hasDrawer) {
|
||||
final deviceNode = ref.watch(currentDeviceProvider);
|
||||
|
51
lib/app/views/responsive_dialog.dart
Executable file
51
lib/app/views/responsive_dialog.dart
Executable file
@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ResponsiveDialog extends StatelessWidget {
|
||||
final Widget? title;
|
||||
final Widget child;
|
||||
final List<Widget> actions;
|
||||
|
||||
const ResponsiveDialog(
|
||||
{Key? key, required this.child, this.title, this.actions = const []})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
LayoutBuilder(builder: ((context, constraints) {
|
||||
if (constraints.maxWidth < 540) {
|
||||
// Fullscreen
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(0),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: title,
|
||||
actions: actions,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
child: child,
|
||||
),
|
||||
));
|
||||
} else {
|
||||
// Dialog
|
||||
return AlertDialog(
|
||||
insetPadding: EdgeInsets.zero,
|
||||
title: title,
|
||||
scrollable: true,
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: child,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
...actions
|
||||
],
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:yubico_authenticator/app/views/app_loading_screen.dart';
|
||||
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../app/views/app_failure_screen.dart';
|
||||
import '../../app/views/app_loading_screen.dart';
|
||||
import '../../app/views/responsive_dialog.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
|
||||
@ -92,37 +94,22 @@ class _ModeFormState extends State<_ModeForm> {
|
||||
}
|
||||
}
|
||||
|
||||
class _CapabilitiesForm extends StatefulWidget {
|
||||
final DeviceInfo info;
|
||||
final Function(Map<Transport, int>) onSubmit;
|
||||
class _CapabilitiesForm extends StatelessWidget {
|
||||
final Map<Transport, int> supported;
|
||||
final Map<Transport, int> enabled;
|
||||
final Function(Map<Transport, int> enabled) onChanged;
|
||||
|
||||
const _CapabilitiesForm(this.info, {required this.onSubmit, Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _CapabilitiesFormState();
|
||||
}
|
||||
|
||||
class _CapabilitiesFormState extends State<_CapabilitiesForm> {
|
||||
late Map<Transport, int> _enabled;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Make sure to copy enabledCapabilites, not mutate the original.
|
||||
_enabled = {...widget.info.config.enabledCapabilities};
|
||||
}
|
||||
const _CapabilitiesForm({
|
||||
required this.onChanged,
|
||||
required this.supported,
|
||||
required this.enabled,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final usbCapabilities =
|
||||
widget.info.supportedCapabilities[Transport.usb] ?? 0;
|
||||
final nfcCapabilities =
|
||||
widget.info.supportedCapabilities[Transport.nfc] ?? 0;
|
||||
|
||||
final changed =
|
||||
!_mapEquals(widget.info.config.enabledCapabilities, _enabled);
|
||||
final valid = changed && (_enabled[Transport.usb] ?? 0) > 0;
|
||||
final usbCapabilities = supported[Transport.usb] ?? 0;
|
||||
final nfcCapabilities = supported[Transport.nfc] ?? 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -134,11 +121,9 @@ class _CapabilitiesFormState extends State<_CapabilitiesForm> {
|
||||
),
|
||||
_CapabilityForm(
|
||||
capabilities: usbCapabilities,
|
||||
enabled: _enabled[Transport.usb] ?? 0,
|
||||
onChanged: (enabled) {
|
||||
setState(() {
|
||||
_enabled[Transport.usb] = enabled;
|
||||
});
|
||||
enabled: enabled[Transport.usb] ?? 0,
|
||||
onChanged: (value) {
|
||||
onChanged({...enabled, Transport.usb: value});
|
||||
},
|
||||
),
|
||||
if (nfcCapabilities != 0)
|
||||
@ -148,74 +133,87 @@ class _CapabilitiesFormState extends State<_CapabilitiesForm> {
|
||||
),
|
||||
_CapabilityForm(
|
||||
capabilities: nfcCapabilities,
|
||||
enabled: _enabled[Transport.nfc] ?? 0,
|
||||
onChanged: (enabled) {
|
||||
setState(() {
|
||||
_enabled[Transport.nfc] = enabled;
|
||||
});
|
||||
enabled: enabled[Transport.nfc] ?? 0,
|
||||
onChanged: (value) {
|
||||
onChanged({...enabled, Transport.nfc: value});
|
||||
},
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: valid
|
||||
? () {
|
||||
widget.onSubmit(_enabled);
|
||||
}
|
||||
: null,
|
||||
child: const Text('Apply changes'),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ManagementScreen extends ConsumerWidget {
|
||||
class ManagementScreen extends ConsumerStatefulWidget {
|
||||
final YubiKeyData deviceData;
|
||||
const ManagementScreen(this.deviceData, {Key? key}) : super(key: key);
|
||||
|
||||
Widget _buildCapabilitiesForm(
|
||||
BuildContext context, WidgetRef ref, DeviceInfo info) =>
|
||||
_CapabilitiesForm(info, onSubmit: (enabled) async {
|
||||
final bool reboot;
|
||||
if (deviceData.node is UsbYubiKeyNode) {
|
||||
// Reboot if USB device descriptor is changed.
|
||||
final oldInterfaces = UsbInterfaces.forCapabilites(
|
||||
info.config.enabledCapabilities[Transport.usb] ?? 0);
|
||||
final newInterfaces =
|
||||
UsbInterfaces.forCapabilites(enabled[Transport.usb] ?? 0);
|
||||
reboot = oldInterfaces != newInterfaces;
|
||||
} else {
|
||||
reboot = false;
|
||||
}
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_ManagementScreenState();
|
||||
}
|
||||
|
||||
Function()? close;
|
||||
try {
|
||||
if (reboot) {
|
||||
// This will take longer, show a message
|
||||
close = ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(
|
||||
content: Text('Reconfiguring YubiKey...'),
|
||||
duration: Duration(seconds: 8),
|
||||
))
|
||||
.close;
|
||||
}
|
||||
await ref
|
||||
.read(managementStateProvider(deviceData.node.path).notifier)
|
||||
.writeConfig(
|
||||
info.config.copyWith(enabledCapabilities: enabled),
|
||||
reboot: reboot,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Configuration updated'),
|
||||
duration: Duration(seconds: 2),
|
||||
));
|
||||
} finally {
|
||||
close?.call();
|
||||
}
|
||||
});
|
||||
class _ManagementScreenState extends ConsumerState<ManagementScreen> {
|
||||
late Map<Transport, int> _enabled;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_enabled = widget.deviceData.info.config.enabledCapabilities;
|
||||
}
|
||||
|
||||
Widget _buildCapabilitiesForm(
|
||||
BuildContext context, WidgetRef ref, DeviceInfo info) {
|
||||
return _CapabilitiesForm(
|
||||
supported: widget.deviceData.info.supportedCapabilities,
|
||||
enabled: _enabled,
|
||||
onChanged: (enabled) {
|
||||
setState(() {
|
||||
_enabled = enabled;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _submitCapabilitiesForm() async {
|
||||
final bool reboot;
|
||||
if (widget.deviceData.node is UsbYubiKeyNode) {
|
||||
// Reboot if USB device descriptor is changed.
|
||||
final oldInterfaces = UsbInterfaces.forCapabilites(
|
||||
widget.deviceData.info.config.enabledCapabilities[Transport.usb] ??
|
||||
0);
|
||||
final newInterfaces =
|
||||
UsbInterfaces.forCapabilites(_enabled[Transport.usb] ?? 0);
|
||||
reboot = oldInterfaces != newInterfaces;
|
||||
} else {
|
||||
reboot = false;
|
||||
}
|
||||
|
||||
Function()? close;
|
||||
try {
|
||||
if (reboot) {
|
||||
// This will take longer, show a message
|
||||
close = ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(
|
||||
content: Text('Reconfiguring YubiKey...'),
|
||||
duration: Duration(seconds: 8),
|
||||
))
|
||||
.close;
|
||||
}
|
||||
await ref
|
||||
.read(managementStateProvider(widget.deviceData.node.path).notifier)
|
||||
.writeConfig(
|
||||
widget.deviceData.info.config
|
||||
.copyWith(enabledCapabilities: _enabled),
|
||||
reboot: reboot,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Configuration updated'),
|
||||
duration: Duration(seconds: 2),
|
||||
));
|
||||
} finally {
|
||||
close?.call();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildModeForm(BuildContext context, WidgetRef ref, DeviceInfo info) =>
|
||||
_ModeForm(
|
||||
@ -229,15 +227,41 @@ class ManagementScreen extends ConsumerWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) =>
|
||||
ref.watch(managementStateProvider(deviceData.node.path)).when(
|
||||
none: () => const AppLoadingScreen(),
|
||||
failure: (reason) => AppFailureScreen(reason),
|
||||
success: (info) => ListView(
|
||||
children: [
|
||||
info.version.major > 4
|
||||
? _buildCapabilitiesForm(context, ref, info)
|
||||
: _buildModeForm(context, ref, info),
|
||||
],
|
||||
));
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<DeviceNode?>(currentDeviceProvider, (_, __) {
|
||||
//TODO: This can probably be checked better to make sure it's the main page.
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
});
|
||||
|
||||
bool changed = false;
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: const Text('Toggle applications'),
|
||||
child:
|
||||
ref.watch(managementStateProvider(widget.deviceData.node.path)).when(
|
||||
none: () => const AppLoadingScreen(),
|
||||
failure: (reason) => AppFailureScreen(reason),
|
||||
success: (info) {
|
||||
// TODO: Check mode for < YK5 intead
|
||||
changed = !_mapEquals(
|
||||
_enabled,
|
||||
info.config.enabledCapabilities,
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
info.version.major > 4
|
||||
? _buildCapabilitiesForm(context, ref, info)
|
||||
: _buildModeForm(context, ref, info),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: changed ? _submitCapabilitiesForm : null,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -19,11 +19,10 @@ List<MenuAction> buildOathMenuActions(AutoDisposeProviderRef ref) {
|
||||
text: 'Add credential',
|
||||
icon: const Icon(Icons.add),
|
||||
action: (context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
OathAddAccountPage(device: device),
|
||||
),
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
OathAddAccountPage(device: device),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -152,9 +152,10 @@ mixin AccountMixin {
|
||||
Future<bool> deleteCredential(BuildContext context, WidgetRef ref) async {
|
||||
final node = ref.read(currentDeviceProvider)!;
|
||||
return await showDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteAccountDialog(node, credential),
|
||||
);
|
||||
context: context,
|
||||
builder: (context) => DeleteAccountDialog(node, credential),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@protected
|
||||
|
@ -3,10 +3,11 @@ import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:yubico_authenticator/oath/models.dart';
|
||||
|
||||
import '../../app/state.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/views/responsive_dialog.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'utils.dart';
|
||||
|
||||
@ -15,21 +16,21 @@ final _secretFormatterPattern =
|
||||
|
||||
enum _QrScanState { none, scanning, success, failed }
|
||||
|
||||
class AddAccountForm extends ConsumerStatefulWidget {
|
||||
final Function(CredentialData, bool) onSubmit;
|
||||
const AddAccountForm({Key? key, required this.onSubmit}) : super(key: key);
|
||||
class OathAddAccountPage extends ConsumerStatefulWidget {
|
||||
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
|
||||
final DeviceNode device;
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _AddAccountFormState();
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_OathAddAccountPageState();
|
||||
}
|
||||
|
||||
class _AddAccountFormState extends ConsumerState<AddAccountForm> {
|
||||
class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
final _issuerController = TextEditingController();
|
||||
final _accountController = TextEditingController();
|
||||
final _secretController = TextEditingController();
|
||||
final _periodController = TextEditingController(text: '$defaultPeriod');
|
||||
bool _touch = false;
|
||||
bool _advanced = false;
|
||||
OathType _oathType = defaultOathType;
|
||||
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
|
||||
int _digits = defaultDigits;
|
||||
@ -83,6 +84,12 @@ class _AddAccountFormState extends ConsumerState<AddAccountForm> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// If current device changes, we need to pop back to the main Page.
|
||||
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
|
||||
//TODO: This can probably be checked better to make sure it's the main page.
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
});
|
||||
|
||||
final period = int.tryParse(_periodController.text) ?? -1;
|
||||
final remaining = getRemainingKeySpace(
|
||||
oathType: _oathType,
|
||||
@ -100,277 +107,237 @@ class _AddAccountFormState extends ConsumerState<AddAccountForm> {
|
||||
|
||||
final qrScanner = ref.watch(qrScannerProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _issuerController,
|
||||
autofocus: true,
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: max(issuerRemaining, 1),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Issuer (optional)',
|
||||
helperText:
|
||||
'', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _accountController,
|
||||
maxLength: nameRemaining,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Account name',
|
||||
helperText:
|
||||
'', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _secretController,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(_secretFormatterPattern)
|
||||
return ResponsiveDialog(
|
||||
title: const Text('Add account'),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Account details',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
TextField(
|
||||
controller: _issuerController,
|
||||
autofocus: true,
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: max(issuerRemaining, 1),
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Issuer (optional)',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _accountController,
|
||||
maxLength: nameRemaining,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Account name',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _secretController,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(_secretFormatterPattern)
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'Secret key',
|
||||
errorText: _validateSecretLength && !secretLengthValid
|
||||
? 'Invalid length'
|
||||
: null),
|
||||
enabled: _qrState != _QrScanState.success,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validateSecretLength = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (qrScanner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_scanQrCode(qrScanner);
|
||||
},
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text('Scan QR code'),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
..._buildQrStatus(),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Secret key',
|
||||
errorText: _validateSecretLength && !secretLengthValid
|
||||
? 'Invalid length'
|
||||
: null),
|
||||
enabled: _qrState != _QrScanState.success,
|
||||
onChanged: (value) {
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Text(
|
||||
'Options',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Require touch'),
|
||||
selected: _touch,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_validateSecretLength = false;
|
||||
_touch = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (qrScanner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_scanQrCode(qrScanner);
|
||||
},
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text('Scan QR code'),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
..._buildQrStatus(),
|
||||
],
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OathType>(
|
||||
value: _oathType,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: OathType.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (type) {
|
||||
setState(() {
|
||||
_oathType = type ?? OathType.totp;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<HashAlgorithm>(
|
||||
value: _hashAlgorithm,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: HashAlgorithm.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (type) {
|
||||
setState(() {
|
||||
_hashAlgorithm = type ?? HashAlgorithm.sha1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_oathType == OathType.totp)
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value:
|
||||
int.tryParse(_periodController.text) ?? defaultPeriod,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: [20, 30, 45, 60]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text('$e sec'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (period) {
|
||||
setState(() {
|
||||
_periodController.text =
|
||||
'${period ?? defaultPeriod}';
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _digits,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: [6, 7, 8]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text('$e digits'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (digits) {
|
||||
setState(() {
|
||||
_digits = digits ?? defaultDigits;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Require touch'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: _touch,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_touch = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Show advanced settings'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: _advanced,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_advanced = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_advanced)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<OathType>(
|
||||
decoration: const InputDecoration(labelText: 'Type'),
|
||||
value: _oathType,
|
||||
items: OathType.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (type) {
|
||||
setState(() {
|
||||
_oathType = type ?? OathType.totp;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: isValid
|
||||
? () {
|
||||
if (secretLengthValid) {
|
||||
final issuer = _issuerController.text;
|
||||
|
||||
final cred = CredentialData(
|
||||
issuer: issuer.isEmpty ? null : issuer,
|
||||
name: _accountController.text,
|
||||
secret: secret,
|
||||
oathType: _oathType,
|
||||
hashAlgorithm: _hashAlgorithm,
|
||||
digits: _digits,
|
||||
period: period,
|
||||
);
|
||||
|
||||
ref
|
||||
.read(
|
||||
credentialListProvider(widget.device.path).notifier)
|
||||
.addAccount(cred.toUri(), requireTouch: _touch);
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Account added'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<HashAlgorithm>(
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Algorithm'),
|
||||
value: _hashAlgorithm,
|
||||
items: HashAlgorithm.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (type) {
|
||||
setState(() {
|
||||
_hashAlgorithm = type ?? HashAlgorithm.sha1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_oathType == OathType.totp)
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _periodController,
|
||||
enabled: _qrState != _QrScanState.success,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
// Manual alignment to match digits-dropdown.
|
||||
const EdgeInsets.fromLTRB(0, 12, 0, 15),
|
||||
labelText: 'Period',
|
||||
errorText:
|
||||
period > 0 ? null : 'Must be a positive number',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_oathType == OathType.totp)
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
decoration: const InputDecoration(labelText: 'Digits'),
|
||||
value: _digits,
|
||||
items: [6, 7, 8]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.toString()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (value) {
|
||||
setState(() {
|
||||
_digits = value ?? defaultDigits;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: isValid
|
||||
? () {
|
||||
if (secretLengthValid) {
|
||||
final issuer = _issuerController.text;
|
||||
widget.onSubmit(
|
||||
CredentialData(
|
||||
issuer: issuer.isEmpty ? null : issuer,
|
||||
name: _accountController.text,
|
||||
secret: secret,
|
||||
oathType: _oathType,
|
||||
hashAlgorithm: _hashAlgorithm,
|
||||
digits: _digits,
|
||||
period: period,
|
||||
),
|
||||
_touch,
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_validateSecretLength = true;
|
||||
});
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_validateSecretLength = true;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: const Text('Add account'),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OathAddAccountPage extends ConsumerWidget {
|
||||
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
|
||||
final DeviceNode device;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// If current device changes, we need to pop back to the main Page.
|
||||
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
|
||||
//TODO: This can probably be checked better to make sure it's the main page.
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Add account'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
AddAccountForm(
|
||||
onSubmit: (cred, requireTouch) {
|
||||
ref
|
||||
.read(credentialListProvider(device.path).notifier)
|
||||
.addAccount(cred.toUri(), requireTouch: requireTouch);
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Account added'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/views/responsive_dialog.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import '../../app/models.dart';
|
||||
@ -23,28 +24,27 @@ class DeleteAccountDialog extends ConsumerWidget {
|
||||
? '${credential.issuer} (${credential.name})'
|
||||
: credential.name;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('Delete $label?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return ResponsiveDialog(
|
||||
title: const Text('Delete account'),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Warning! This action will delete the account from your YubiKey.'),
|
||||
const Text(''),
|
||||
Text(
|
||||
'You will no longer be able to generate OTPs for this account. Make sure to first disable this credential from the website to avoid being locked out of your account.',
|
||||
style: Theme.of(context).textTheme.bodyText1,
|
||||
),
|
||||
],
|
||||
Text('Account: $label'),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
child: e,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
actions: [
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(credentialListProvider(device.path).notifier)
|
||||
@ -57,7 +57,7 @@ class DeleteAccountDialog extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Delete account'),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -1,20 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/views/responsive_dialog.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
|
||||
class ManagePasswordDialog extends ConsumerStatefulWidget {
|
||||
final DeviceNode device;
|
||||
const ManagePasswordDialog(this.device, {Key? key}) : super(key: key);
|
||||
class _ManagePasswordForm extends ConsumerStatefulWidget {
|
||||
final DevicePath path;
|
||||
final OathState state;
|
||||
const _ManagePasswordForm(this.path, this.state, {Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_ManagePasswordDialogState();
|
||||
_ManagePasswordFormState();
|
||||
}
|
||||
|
||||
class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
|
||||
class _ManagePasswordFormState extends ConsumerState<_ManagePasswordForm> {
|
||||
String _currentPassword = '';
|
||||
String _newPassword = '';
|
||||
String _confirmPassword = '';
|
||||
@ -22,185 +26,42 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// If current device changes, we need to pop back to the main Page.
|
||||
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
|
||||
return ref.watch(oathStateProvider(widget.device.path)).maybeWhen(
|
||||
success: (state) => AlertDialog(
|
||||
title: const Text('Manage password'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (state.hasKey)
|
||||
Column(
|
||||
children: [
|
||||
if (state.remembered)
|
||||
// TODO: This is temporary, to be able to forget a password.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: const [
|
||||
Text(
|
||||
'Your password is remembered by the app.',
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(oathStateProvider(
|
||||
widget.device.path)
|
||||
.notifier)
|
||||
.forgetPassword();
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password forgotten'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Forget'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Enter your current password to change it. If you don\'t know your password, you\'ll need to reset the YubiKey, then create a new password.'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Current',
|
||||
errorText: _currentIsWrong
|
||||
? 'Wrong password'
|
||||
: null),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_currentPassword = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
child: const Text('Remove'),
|
||||
onPressed: _currentPassword.isNotEmpty
|
||||
? () async {
|
||||
final result = await ref
|
||||
.read(oathStateProvider(
|
||||
widget.device.path)
|
||||
.notifier)
|
||||
.unsetPassword(_currentPassword);
|
||||
if (result) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Password removed'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_currentIsWrong = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
'Enter your new password. A password may contain letters, numbers and other characters.'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
autofocus: !state.hasKey,
|
||||
obscureText: true,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Password'),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_newPassword = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Confirm',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_confirmPassword = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.state.hasKey) ...[
|
||||
Text(
|
||||
'Current password',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
TextField(
|
||||
autofocus: true,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'Current password',
|
||||
errorText: _currentIsWrong ? 'Wrong password' : null),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_currentPassword = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _newPassword.isNotEmpty &&
|
||||
_newPassword == _confirmPassword &&
|
||||
(!state.hasKey || _currentPassword.isNotEmpty)
|
||||
child: const Text('Remove password'),
|
||||
onPressed: _currentPassword.isNotEmpty
|
||||
? () async {
|
||||
final result = await ref
|
||||
.read(
|
||||
oathStateProvider(widget.device.path).notifier)
|
||||
.setPassword(_currentPassword, _newPassword);
|
||||
.read(oathStateProvider(widget.path).notifier)
|
||||
.unsetPassword(_currentPassword);
|
||||
if (result) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password set'),
|
||||
content: Text('Password removed'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@ -211,10 +72,117 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: const Text('Save'),
|
||||
)
|
||||
),
|
||||
if (widget.state.remembered)
|
||||
OutlinedButton(
|
||||
child: const Text('Clear saved password'),
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(oathStateProvider(widget.path).notifier)
|
||||
.forgetPassword();
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password forgotten'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
Text(
|
||||
'New password',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
TextField(
|
||||
autofocus: !widget.state.hasKey,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'New password',
|
||||
enabled: !widget.state.hasKey || _currentPassword.isNotEmpty,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_newPassword = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'Confirm password',
|
||||
enabled: _newPassword.isNotEmpty,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_confirmPassword = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: _newPassword.isNotEmpty &&
|
||||
_newPassword == _confirmPassword &&
|
||||
(!widget.state.hasKey || _currentPassword.isNotEmpty)
|
||||
? () async {
|
||||
final result = await ref
|
||||
.read(oathStateProvider(widget.path).notifier)
|
||||
.setPassword(_currentPassword, _newPassword);
|
||||
if (result) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password set'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_currentIsWrong = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
)
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ManagePasswordDialog extends ConsumerWidget {
|
||||
final DeviceNode device;
|
||||
const ManagePasswordDialog(this.device, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// If current device changes, we need to pop back to the main Page.
|
||||
ref.listen<DeviceNode?>(currentDeviceProvider, (previous, next) {
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
|
||||
return ref.watch(oathStateProvider(device.path)).maybeWhen(
|
||||
success: (state) => ResponsiveDialog(
|
||||
title: const Text('Manage password'),
|
||||
child: _ManagePasswordForm(
|
||||
device.path,
|
||||
state,
|
||||
// Prevents from losing state on responsive change.
|
||||
key: GlobalKey(),
|
||||
),
|
||||
),
|
||||
orElse: () {
|
||||
throw Exception(
|
||||
'Attempted to show password dialog without an OathState');
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/views/responsive_dialog.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import '../../app/models.dart';
|
||||
@ -53,11 +54,12 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
final nameRemaining = remaining.second;
|
||||
final isValid = _account.isNotEmpty;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('Rename $label?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return ResponsiveDialog(
|
||||
title: const Text('Rename account'),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Rename $label?'),
|
||||
const Text(
|
||||
'This will change how the account is displayed in the list.'),
|
||||
TextFormField(
|
||||
@ -65,6 +67,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: issuerRemaining > 0 ? issuerRemaining : null,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Issuer (optional)',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
@ -78,6 +81,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
initialValue: _account,
|
||||
maxLength: nameRemaining,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'Account name',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
errorText: isValid ? null : 'Your account must have a name',
|
||||
@ -88,16 +92,15 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
]
|
||||
.map((e) => Padding(
|
||||
child: e,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
actions: [
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
TextButton(
|
||||
onPressed: isValid
|
||||
? () async {
|
||||
final renamed = await ref
|
||||
@ -113,7 +116,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: const Text('Rename account'),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/views/responsive_dialog.dart';
|
||||
import '../state.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
@ -16,28 +17,25 @@ class ResetDialog extends ConsumerWidget {
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Reset to defaults?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return ResponsiveDialog(
|
||||
title: const Text('Factory reset'),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Warning! This will irrevocably delete all OATH TOTP/HOTP accounts from your YubiKey.'),
|
||||
const Text(''),
|
||||
Text(
|
||||
'Your OATH credentials, as well as any password set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.',
|
||||
style: Theme.of(context).textTheme.bodyText1,
|
||||
),
|
||||
],
|
||||
]
|
||||
.map((e) => Padding(
|
||||
child: e,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
actions: [
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(oathStateProvider(device.path).notifier).reset();
|
||||
Navigator.of(context).pop();
|
||||
@ -48,7 +46,7 @@ class ResetDialog extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Reset YubiKey'),
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:yubico_authenticator/app/state.dart';
|
||||
|
||||
import 'app/state.dart';
|
||||
import 'app/views/responsive_dialog.dart';
|
||||
import 'core/state.dart';
|
||||
|
||||
final _log = Logger('settings');
|
||||
@ -12,48 +13,41 @@ class SettingsPage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<ThemeMode>(
|
||||
decoration: const InputDecoration(labelText: 'Theme'),
|
||||
value: ref.watch(themeModeProvider),
|
||||
items: [ThemeMode.system, ThemeMode.dark, ThemeMode.light]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(
|
||||
e.name[0].toUpperCase() + e.name.substring(1)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (mode) {
|
||||
ref.read(themeModeProvider.notifier).setThemeMode(mode!);
|
||||
},
|
||||
),
|
||||
DropdownButtonFormField<Level>(
|
||||
decoration: const InputDecoration(labelText: 'Logging'),
|
||||
value: ref.watch(logLevelProvider),
|
||||
items: [Level.INFO, Level.CONFIG, Level.FINE]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (level) {
|
||||
ref.read(logLevelProvider.notifier).setLogLevel(level!);
|
||||
_log.config('Log level set to $level');
|
||||
},
|
||||
),
|
||||
],
|
||||
return ResponsiveDialog(
|
||||
title: const Text('Settings'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<ThemeMode>(
|
||||
decoration: const InputDecoration(labelText: 'Theme'),
|
||||
value: ref.watch(themeModeProvider),
|
||||
items: [ThemeMode.system, ThemeMode.dark, ThemeMode.light]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child:
|
||||
Text(e.name[0].toUpperCase() + e.name.substring(1)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (mode) {
|
||||
ref.read(themeModeProvider.notifier).setThemeMode(mode!);
|
||||
},
|
||||
),
|
||||
),
|
||||
DropdownButtonFormField<Level>(
|
||||
decoration: const InputDecoration(labelText: 'Logging'),
|
||||
value: ref.watch(logLevelProvider),
|
||||
items: [Level.INFO, Level.CONFIG, Level.FINE]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (level) {
|
||||
ref.read(logLevelProvider.notifier).setLogLevel(level!);
|
||||
_log.config('Log level set to $level');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user