2022-01-28 19:05:05 +03:00
|
|
|
import 'dart:math';
|
2022-03-22 17:16:52 +03:00
|
|
|
import 'dart:convert';
|
2022-01-28 19:05:05 +03:00
|
|
|
|
2021-11-22 11:49:52 +03:00
|
|
|
import 'package:flutter/material.dart';
|
2022-01-28 19:05:05 +03:00
|
|
|
import 'package:flutter/services.dart';
|
2021-11-22 11:49:52 +03:00
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
|
|
|
import '../../app/state.dart';
|
|
|
|
import '../../app/models.dart';
|
2022-03-15 20:04:26 +03:00
|
|
|
import '../../app/views/responsive_dialog.dart';
|
2022-03-23 12:46:35 +03:00
|
|
|
import '../../widgets/file_drop_target.dart';
|
2022-03-15 20:04:26 +03:00
|
|
|
import '../models.dart';
|
2021-12-02 16:20:05 +03:00
|
|
|
import '../state.dart';
|
2022-01-31 13:02:34 +03:00
|
|
|
import 'utils.dart';
|
2021-11-22 11:49:52 +03:00
|
|
|
|
2022-01-28 19:05:05 +03:00
|
|
|
final _secretFormatterPattern =
|
|
|
|
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
|
|
|
|
|
2022-02-11 16:56:35 +03:00
|
|
|
enum _QrScanState { none, scanning, success, failed }
|
|
|
|
|
2022-03-15 20:04:26 +03:00
|
|
|
class OathAddAccountPage extends ConsumerStatefulWidget {
|
|
|
|
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
|
|
|
|
final DeviceNode device;
|
2022-01-28 19:05:05 +03:00
|
|
|
|
2021-12-02 16:20:05 +03:00
|
|
|
@override
|
2022-03-15 20:04:26 +03:00
|
|
|
ConsumerState<ConsumerStatefulWidget> createState() =>
|
|
|
|
_OathAddAccountPageState();
|
2021-12-02 16:20:05 +03:00
|
|
|
}
|
|
|
|
|
2022-03-15 20:04:26 +03:00
|
|
|
class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
2022-02-11 16:56:35 +03:00
|
|
|
final _issuerController = TextEditingController();
|
|
|
|
final _accountController = TextEditingController();
|
|
|
|
final _secretController = TextEditingController();
|
|
|
|
final _periodController = TextEditingController(text: '$defaultPeriod');
|
2021-12-02 16:20:05 +03:00
|
|
|
bool _touch = false;
|
2022-01-28 19:05:05 +03:00
|
|
|
OathType _oathType = defaultOathType;
|
|
|
|
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
|
|
|
|
int _digits = defaultDigits;
|
2022-02-01 10:24:29 +03:00
|
|
|
bool _validateSecretLength = false;
|
2022-02-11 16:56:35 +03:00
|
|
|
_QrScanState _qrState = _QrScanState.none;
|
2022-04-04 13:09:34 +03:00
|
|
|
bool isObscure = true;
|
2022-02-11 16:56:35 +03:00
|
|
|
|
|
|
|
_scanQrCode(QrScanner qrScanner) async {
|
|
|
|
try {
|
|
|
|
setState(() {
|
|
|
|
_qrState = _QrScanState.scanning;
|
|
|
|
});
|
2022-03-29 14:57:08 +03:00
|
|
|
final otpauth = await qrScanner.scanQr();
|
2022-02-11 16:56:35 +03:00
|
|
|
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;
|
|
|
|
});
|
|
|
|
} catch (e) {
|
|
|
|
setState(() {
|
|
|
|
_qrState = _QrScanState.failed;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
List<Widget> _buildQrStatus() {
|
|
|
|
switch (_qrState) {
|
|
|
|
case _QrScanState.success:
|
|
|
|
return const [
|
|
|
|
Icon(Icons.check_circle_outline_outlined),
|
|
|
|
Text('QR code scanned!'),
|
|
|
|
];
|
|
|
|
case _QrScanState.scanning:
|
|
|
|
return const [
|
|
|
|
SizedBox.square(dimension: 16.0, child: CircularProgressIndicator()),
|
|
|
|
];
|
|
|
|
case _QrScanState.failed:
|
|
|
|
return const [
|
|
|
|
Icon(Icons.warning_amber_rounded),
|
|
|
|
Text('No QR code found'),
|
|
|
|
];
|
|
|
|
default:
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
}
|
2021-11-22 11:49:52 +03:00
|
|
|
|
|
|
|
@override
|
2021-12-02 16:20:05 +03:00
|
|
|
Widget build(BuildContext context) {
|
2022-03-15 20:04:26 +03:00
|
|
|
// 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);
|
|
|
|
});
|
|
|
|
|
2022-02-11 16:56:35 +03:00
|
|
|
final period = int.tryParse(_periodController.text) ?? -1;
|
2022-01-31 13:02:34 +03:00
|
|
|
final remaining = getRemainingKeySpace(
|
|
|
|
oathType: _oathType,
|
2022-02-11 16:56:35 +03:00
|
|
|
period: period,
|
|
|
|
issuer: _issuerController.text,
|
|
|
|
name: _accountController.text,
|
2022-01-31 13:02:34 +03:00
|
|
|
);
|
|
|
|
final issuerRemaining = remaining.first;
|
|
|
|
final nameRemaining = remaining.second;
|
2021-11-22 11:49:52 +03:00
|
|
|
|
2022-02-11 16:56:35 +03:00
|
|
|
final secret = _secretController.text.replaceAll(' ', '');
|
|
|
|
final secretLengthValid = secret.length * 5 % 8 < 5;
|
2022-04-01 16:05:26 +03:00
|
|
|
final isValid = _accountController.text.isNotEmpty &&
|
|
|
|
secret.isNotEmpty &&
|
|
|
|
issuerRemaining >= -1 &&
|
|
|
|
nameRemaining >= 0 &&
|
|
|
|
period > 0;
|
2022-02-11 16:56:35 +03:00
|
|
|
|
|
|
|
final qrScanner = ref.watch(qrScannerProvider);
|
2022-01-28 19:05:05 +03:00
|
|
|
|
2022-03-15 20:04:26 +03:00
|
|
|
return ResponsiveDialog(
|
|
|
|
title: const Text('Add account'),
|
2022-03-23 12:46:35 +03:00
|
|
|
child: FileDropTarget(
|
|
|
|
onFileDropped: (fileData) async {
|
|
|
|
if (qrScanner != null) {
|
|
|
|
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;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
child: Column(
|
2022-03-22 17:16:52 +03:00
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
'Account details',
|
|
|
|
style: Theme.of(context).textTheme.headline6,
|
2022-03-15 20:04:26 +03:00
|
|
|
),
|
2022-03-22 17:16:52 +03:00
|
|
|
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
|
2022-03-15 20:04:26 +03:00
|
|
|
),
|
2022-03-22 17:16:52 +03:00
|
|
|
onChanged: (value) {
|
|
|
|
setState(() {
|
|
|
|
// Update maxlengths
|
|
|
|
});
|
|
|
|
},
|
2022-03-15 20:04:26 +03:00
|
|
|
),
|
2022-03-22 17:16:52 +03:00
|
|
|
TextField(
|
|
|
|
controller: _accountController,
|
2022-04-01 16:05:26 +03:00
|
|
|
maxLength: max(nameRemaining, 1),
|
2022-03-22 17:16:52 +03:00
|
|
|
decoration: const InputDecoration(
|
|
|
|
border: OutlineInputBorder(),
|
|
|
|
labelText: 'Account name',
|
|
|
|
helperText: '', // Prevents dialog resizing when enabled = false
|
2021-12-02 16:20:05 +03:00
|
|
|
),
|
2022-03-22 17:16:52 +03:00
|
|
|
onChanged: (value) {
|
|
|
|
setState(() {
|
|
|
|
// Update maxlengths
|
|
|
|
});
|
|
|
|
},
|
|
|
|
),
|
|
|
|
TextField(
|
|
|
|
controller: _secretController,
|
2022-04-04 13:09:34 +03:00
|
|
|
obscureText: isObscure,
|
2022-03-22 17:16:52 +03:00
|
|
|
inputFormatters: <TextInputFormatter>[
|
|
|
|
FilteringTextInputFormatter.allow(_secretFormatterPattern)
|
|
|
|
],
|
|
|
|
decoration: InputDecoration(
|
2022-04-04 13:09:34 +03:00
|
|
|
suffixIcon: IconButton(
|
|
|
|
icon: Icon(
|
|
|
|
isObscure ? Icons.visibility : Icons.visibility_off,
|
|
|
|
),
|
|
|
|
onPressed: () {
|
|
|
|
setState(() {
|
|
|
|
isObscure = !isObscure;
|
|
|
|
});
|
|
|
|
},
|
|
|
|
),
|
2022-03-22 17:16:52 +03:00
|
|
|
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(),
|
|
|
|
],
|
2022-01-28 19:05:05 +03:00
|
|
|
),
|
2021-12-02 16:20:05 +03:00
|
|
|
),
|
2022-03-22 17:16:52 +03:00
|
|
|
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(() {
|
|
|
|
_touch = value;
|
|
|
|
});
|
|
|
|
},
|
|
|
|
),
|
|
|
|
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,
|
|
|
|
),
|
2022-02-11 16:56:35 +03:00
|
|
|
),
|
|
|
|
),
|
2022-03-15 20:04:26 +03:00
|
|
|
Chip(
|
|
|
|
label: DropdownButtonHideUnderline(
|
2022-03-22 17:16:52 +03:00
|
|
|
child: DropdownButton<HashAlgorithm>(
|
|
|
|
value: _hashAlgorithm,
|
2022-03-15 20:04:26 +03:00
|
|
|
isDense: true,
|
|
|
|
underline: null,
|
2022-03-22 17:16:52 +03:00
|
|
|
items: HashAlgorithm.values
|
2022-03-15 20:04:26 +03:00
|
|
|
.map((e) => DropdownMenuItem(
|
|
|
|
value: e,
|
2022-03-22 17:16:52 +03:00
|
|
|
child: Text(e.name.toUpperCase()),
|
2022-03-15 20:04:26 +03:00
|
|
|
))
|
|
|
|
.toList(),
|
|
|
|
onChanged: _qrState != _QrScanState.success
|
2022-03-22 17:16:52 +03:00
|
|
|
? (type) {
|
2022-03-15 20:04:26 +03:00
|
|
|
setState(() {
|
2022-03-22 17:16:52 +03:00
|
|
|
_hashAlgorithm = type ?? HashAlgorithm.sha1;
|
2022-03-15 20:04:26 +03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
: null,
|
2022-01-28 19:05:05 +03:00
|
|
|
),
|
2022-03-15 20:04:26 +03:00
|
|
|
),
|
2022-01-28 19:05:05 +03:00
|
|
|
),
|
2022-03-22 17:16:52 +03:00
|
|
|
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,
|
|
|
|
),
|
2022-03-15 20:04:26 +03:00
|
|
|
),
|
2022-01-28 19:05:05 +03:00
|
|
|
),
|
2022-03-22 17:16:52 +03:00
|
|
|
],
|
|
|
|
),
|
|
|
|
]
|
|
|
|
.map((e) => Padding(
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
|
|
child: e,
|
|
|
|
))
|
|
|
|
.toList(),
|
2022-03-23 12:46:35 +03:00
|
|
|
),
|
|
|
|
),
|
2022-03-15 20:04:26 +03:00
|
|
|
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),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
setState(() {
|
|
|
|
_validateSecretLength = true;
|
|
|
|
});
|
2022-01-28 19:05:05 +03:00
|
|
|
}
|
2022-03-15 20:04:26 +03:00
|
|
|
}
|
|
|
|
: null,
|
|
|
|
child: const Text('Save'),
|
2022-01-28 19:05:05 +03:00
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|