mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-22 17:51:29 +03:00
OATH: Add advanced options to add dialog.
This commit is contained in:
parent
7d8a09529e
commit
a5f73f7d94
@ -3,6 +3,12 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'models.freezed.dart';
|
||||
part 'models.g.dart';
|
||||
|
||||
const defaultPeriod = 30;
|
||||
const defaultDigits = 6;
|
||||
const defaultCounter = 0;
|
||||
const defaultOathType = OathType.totp;
|
||||
const defaultHashAlgorithm = HashAlgorithm.sha1;
|
||||
|
||||
enum HashAlgorithm {
|
||||
@JsonValue(0x01)
|
||||
sha1,
|
||||
@ -12,10 +18,6 @@ enum HashAlgorithm {
|
||||
sha512,
|
||||
}
|
||||
|
||||
extension on HashAlgorithm {
|
||||
String get name => toString().split('.').last.toUpperCase();
|
||||
}
|
||||
|
||||
enum OathType {
|
||||
@JsonValue(0x10)
|
||||
hotp,
|
||||
@ -23,10 +25,6 @@ enum OathType {
|
||||
totp,
|
||||
}
|
||||
|
||||
extension on OathType {
|
||||
String get name => toString().split('.').last.toUpperCase();
|
||||
}
|
||||
|
||||
@freezed
|
||||
class OathCredential with _$OathCredential {
|
||||
factory OathCredential(
|
||||
@ -71,11 +69,11 @@ class CredentialData with _$CredentialData {
|
||||
String? issuer,
|
||||
required String name,
|
||||
required String secret,
|
||||
@Default(OathType.totp) OathType oathType,
|
||||
@Default(HashAlgorithm.sha1) HashAlgorithm hashAlgorithm,
|
||||
@Default(6) int digits,
|
||||
@Default(30) int period,
|
||||
@Default(0) int counter,
|
||||
@Default(defaultOathType) OathType oathType,
|
||||
@Default(defaultHashAlgorithm) HashAlgorithm hashAlgorithm,
|
||||
@Default(defaultDigits) int digits,
|
||||
@Default(defaultPeriod) int period,
|
||||
@Default(defaultCounter) int counter,
|
||||
}) = _CredentialData;
|
||||
|
||||
factory CredentialData.fromJson(Map<String, dynamic> json) =>
|
||||
|
@ -1,4 +1,7 @@
|
||||
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';
|
||||
|
||||
@ -6,21 +9,251 @@ import '../../app/state.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../state.dart';
|
||||
|
||||
class OathAddAccountPage extends ConsumerStatefulWidget {
|
||||
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
|
||||
final DeviceNode device;
|
||||
final _secretFormatterPattern =
|
||||
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
|
||||
|
||||
class AddAccountForm extends StatefulWidget {
|
||||
final Function(CredentialData, bool) onSubmit;
|
||||
const AddAccountForm({Key? key, required this.onSubmit}) : super(key: key);
|
||||
|
||||
@override
|
||||
OathAddAccountPageState createState() => OathAddAccountPageState();
|
||||
State<StatefulWidget> createState() => _AddAccountFormState();
|
||||
}
|
||||
|
||||
class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
class _AddAccountFormState extends State<AddAccountForm> {
|
||||
String _issuer = '';
|
||||
String _account = '';
|
||||
String _secret = '';
|
||||
bool _touch = false;
|
||||
bool _advanced = false;
|
||||
OathType _oathType = defaultOathType;
|
||||
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
|
||||
int _period = defaultPeriod;
|
||||
int _digits = defaultDigits;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
int remaining = 64; // 64 bytes are shared between issuer and name.
|
||||
if (_oathType == OathType.totp && _period != defaultPeriod) {
|
||||
// Non-standard periods are stored as part of this data, as a "D/"- prefix.
|
||||
remaining -= '$_period/'.length;
|
||||
}
|
||||
if (_issuer.isNotEmpty) {
|
||||
// Issuer is separated from name with a ":", if present.
|
||||
remaining -= 1;
|
||||
}
|
||||
final issuerRemaining = remaining - max<int>(_account.length, 1);
|
||||
final nameRemaining = remaining - _issuer.length;
|
||||
|
||||
final secretValid = _secret.length * 5 % 8 < 5;
|
||||
final isValid =
|
||||
_account.isNotEmpty && _secret.isNotEmpty && secretValid && _period > 0;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: issuerRemaining > 0 ? issuerRemaining : null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Issuer (optional)',
|
||||
helperText:
|
||||
'', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_issuer = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
enabled: nameRemaining > 0,
|
||||
maxLength: nameRemaining > 0 ? nameRemaining : null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Account name',
|
||||
helperText:
|
||||
'', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_account = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(_secretFormatterPattern)
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Secret key',
|
||||
errorText: secretValid ? null : 'Invalid value'),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_secret = value.replaceAll(' ', '');
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: (type) {
|
||||
setState(() {
|
||||
_oathType = type ?? OathType.totp;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<HashAlgorithm>(
|
||||
decoration:
|
||||
const InputDecoration(label: Text('Algorithm')),
|
||||
value: _hashAlgorithm,
|
||||
items: HashAlgorithm.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (type) {
|
||||
setState(() {
|
||||
_hashAlgorithm = type ?? HashAlgorithm.sha1;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_oathType == OathType.totp)
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: _period > 0 ? _period.toString() : '',
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
label: const Text('Period'),
|
||||
errorText: _period > 0
|
||||
? null
|
||||
: 'Must be a positive number',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_period = int.tryParse(value) ?? -1;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_oathType == OathType.totp)
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
decoration:
|
||||
const InputDecoration(label: Text('Digits')),
|
||||
value: _digits,
|
||||
items: [6, 7, 8]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.toString()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_digits = value ?? defaultDigits;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: isValid
|
||||
? () {
|
||||
widget.onSubmit(
|
||||
CredentialData(
|
||||
issuer: _issuer.isEmpty ? null : _issuer,
|
||||
name: _account,
|
||||
secret: _secret,
|
||||
oathType: _oathType,
|
||||
hashAlgorithm: _hashAlgorithm,
|
||||
digits: _digits,
|
||||
period: _period,
|
||||
),
|
||||
_touch,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: const Text('Add account'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@ -28,77 +261,16 @@ class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Add account'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Issuer',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_issuer = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Account name *',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_account = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Secret key *',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_secret = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: CheckboxListTile(
|
||||
title: const Text('Require touch'),
|
||||
checkColor: Colors.white,
|
||||
value: _touch,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_touch = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
final cred = CredentialData(
|
||||
issuer: _issuer.isEmpty ? null : _issuer,
|
||||
name: _account,
|
||||
secret: _secret);
|
||||
appBar: AppBar(
|
||||
title: const Text('Add account'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
AddAccountForm(
|
||||
onSubmit: (cred, requireTouch) {
|
||||
ref
|
||||
.read(credentialListProvider(widget.device.path).notifier)
|
||||
.addAccount(cred.toUri(), requireTouch: _touch);
|
||||
.read(credentialListProvider(device.path).notifier)
|
||||
.addAccount(cred.toUri(), requireTouch: requireTouch);
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@ -107,11 +279,8 @@ class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
)
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@ -44,7 +46,8 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
: credential.name;
|
||||
|
||||
int remaining = 64; // 64 bytes are shared between issuer and name.
|
||||
if (credential.oathType == OathType.totp && credential.period != 30) {
|
||||
if (credential.oathType == OathType.totp &&
|
||||
credential.period != defaultPeriod) {
|
||||
// Non-standard periods are stored as part of this data, as a "D/"- prefix.
|
||||
remaining -= '${credential.period}/'.length;
|
||||
}
|
||||
@ -52,7 +55,8 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
// Issuer is separated from name with a ":", if present.
|
||||
remaining -= 1;
|
||||
}
|
||||
final issuerRemaining = remaining - _nameController.text.length;
|
||||
final issuerRemaining =
|
||||
remaining - max<int>(_nameController.text.length, 1);
|
||||
final nameRemaining = remaining - _issuerController.text.length;
|
||||
final isValid = _nameController.text.trim().isNotEmpty;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user