diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index 9a766a3e..4d6de63d 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -25,6 +25,7 @@ import '../../widgets/utf8_utils.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; +import 'unlock_form.dart'; import 'utils.dart'; final _log = Logger('oath.view.add_account_page'); @@ -258,7 +259,10 @@ class _OathAddAccountPageState extends ConsumerState { .isEmpty ?? true; - final isValid = _accountController.text.trim().isNotEmpty && + final isLocked = oathState?.locked ?? false; + + final isValid = !isLocked && + _accountController.text.trim().isNotEmpty && secret.isNotEmpty && isUnique && issuerRemaining >= -1 && @@ -267,13 +271,20 @@ class _OathAddAccountPageState extends ConsumerState { final qrScanner = ref.watch(qrScannerProvider); - final hashAlgorithms = HashAlgorithm.values.where((alg) => - alg != HashAlgorithm.sha512 || - (oathState?.version.isAtLeast(4, 3, 1) ?? true)); + final hashAlgorithms = HashAlgorithm.values + .where((alg) => + alg != HashAlgorithm.sha512 || + (oathState?.version.isAtLeast(4, 3, 1) ?? true)) + .toList(); if (!hashAlgorithms.contains(_hashAlgorithm)) { _hashAlgorithm = HashAlgorithm.sha1; } + if (!(oathState?.version.isAtLeast(4, 2) ?? true)) { + // Touch not supported + _touch = false; + } + void submit() async { if (secretLengthValid) { final issuer = _issuerController.text.trim(); @@ -340,195 +351,210 @@ class _OathAddAccountPageState extends ConsumerState { }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - key: keys.issuerField, - controller: _issuerController, - autofocus: !widget.openQrScanner, - enabled: issuerRemaining > 0, - maxLength: max(issuerRemaining, 1), - inputFormatters: [limitBytesLength(issuerRemaining)], - buildCounter: - buildByteCounterFor(_issuerController.text.trim()), - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: AppLocalizations.of(context)!.oath_issuer_optional, - helperText: '', // Prevents dialog resizing when disabled - prefixIcon: const Icon(Icons.business_outlined), - ), - textInputAction: TextInputAction.next, - onChanged: (value) { - setState(() { - // Update maxlengths - }); - }, - onSubmitted: (_) { - if (isValid) submit(); - }, - ), - TextField( - key: keys.nameField, - controller: _accountController, - maxLength: max(nameRemaining, 1), - buildCounter: - buildByteCounterFor(_accountController.text.trim()), - inputFormatters: [limitBytesLength(nameRemaining)], - decoration: InputDecoration( - border: const OutlineInputBorder(), - prefixIcon: const Icon(Icons.person_outline), - labelText: AppLocalizations.of(context)!.oath_account_name, - helperText: '', // Prevents dialog resizing when disabled - errorText: isUnique - ? null - : AppLocalizations.of(context)!.oath_duplicate_name, - ), - textInputAction: TextInputAction.next, - onChanged: (value) { - setState(() { - // Update maxlengths - }); - }, - onSubmitted: (_) { - if (isValid) submit(); - }, - ), - TextField( - key: keys.secretField, - controller: _secretController, - obscureText: _isObscure, - inputFormatters: [ - FilteringTextInputFormatter.allow(_secretFormatterPattern) - ], - decoration: InputDecoration( - suffixIcon: IconButton( - icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off, - color: IconTheme.of(context).color, + child: isLocked + ? UnlockForm(deviceNode!.path, keystore: oathState!.keystore) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: keys.issuerField, + controller: _issuerController, + autofocus: !widget.openQrScanner, + enabled: issuerRemaining > 0, + maxLength: max(issuerRemaining, 1), + inputFormatters: [limitBytesLength(issuerRemaining)], + buildCounter: + buildByteCounterFor(_issuerController.text.trim()), + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: + AppLocalizations.of(context)!.oath_issuer_optional, + helperText: + '', // Prevents dialog resizing when disabled + prefixIcon: const Icon(Icons.business_outlined), ), - onPressed: () { + textInputAction: TextInputAction.next, + onChanged: (value) { setState(() { - _isObscure = !_isObscure; + // Update maxlengths }); }, - ), - border: const OutlineInputBorder(), - prefixIcon: const Icon(Icons.key_outlined), - labelText: AppLocalizations.of(context)!.oath_secret_key, - errorText: _validateSecretLength && !secretLengthValid - ? AppLocalizations.of(context)!.oath_invalid_length - : null), - readOnly: _qrState == _QrScanState.success, - textInputAction: TextInputAction.done, - onChanged: (value) { - setState(() { - _validateSecretLength = false; - }); - }, - onSubmitted: (_) { - if (isValid) submit(); - }, - ), - if (qrScanner != null) - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: ActionChip( - avatar: _qrState != _QrScanState.scanning - ? (_qrState == _QrScanState.success - ? const Icon(Icons.qr_code) - : const Icon(Icons.qr_code_scanner_outlined)) - : const CircularProgressIndicator(strokeWidth: 2.0), - label: _qrState == _QrScanState.success - ? Text(AppLocalizations.of(context)!.oath_scanned_qr) - : Text(AppLocalizations.of(context)!.oath_scan_qr), - onPressed: () { - _scanQrCode(qrScanner); - }), - ), - const Divider(), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4.0, - runSpacing: 8.0, - children: [ - if (widget.state?.version.isAtLeast(4, 2) ?? true) - FilterChip( - label: Text( - AppLocalizations.of(context)!.oath_require_touch), - selected: _touch, - onSelected: (value) { - setState(() { - _touch = value; - }); + onSubmitted: (_) { + if (isValid) submit(); }, ), - ChoiceFilterChip( - items: OathType.values, - value: _oathType, - selected: _oathType != defaultOathType, - itemBuilder: (value) => Text(value.displayName), - onChanged: _qrState != _QrScanState.success - ? (value) { - setState(() { - _oathType = value; - }); - } - : null, - ), - ChoiceFilterChip( - items: HashAlgorithm.values, - value: _hashAlgorithm, - selected: _hashAlgorithm != defaultHashAlgorithm, - itemBuilder: (value) => Text(value.displayName), - onChanged: _qrState != _QrScanState.success - ? (value) { - setState(() { - _hashAlgorithm = value; - }); - } - : null, - ), - if (_oathType == OathType.totp) - ChoiceFilterChip( - items: _periodValues, - value: - int.tryParse(_periodController.text) ?? defaultPeriod, - selected: - int.tryParse(_periodController.text) != defaultPeriod, - itemBuilder: ((value) => Text( - '$value ${AppLocalizations.of(context)!.oath_sec}')), - onChanged: _qrState != _QrScanState.success - ? (period) { + TextField( + key: keys.nameField, + controller: _accountController, + maxLength: max(nameRemaining, 1), + buildCounter: + buildByteCounterFor(_accountController.text.trim()), + inputFormatters: [limitBytesLength(nameRemaining)], + decoration: InputDecoration( + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.person_outline), + labelText: + AppLocalizations.of(context)!.oath_account_name, + helperText: + '', // Prevents dialog resizing when disabled + errorText: isUnique + ? null + : AppLocalizations.of(context)!.oath_duplicate_name, + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + // Update maxlengths + }); + }, + onSubmitted: (_) { + if (isValid) submit(); + }, + ), + TextField( + key: keys.secretField, + controller: _secretController, + obscureText: _isObscure, + inputFormatters: [ + FilteringTextInputFormatter.allow( + _secretFormatterPattern) + ], + decoration: InputDecoration( + suffixIcon: IconButton( + icon: Icon( + _isObscure + ? Icons.visibility + : Icons.visibility_off, + color: IconTheme.of(context).color, + ), + onPressed: () { setState(() { - _periodController.text = '$period'; + _isObscure = !_isObscure; }); - } - : null, + }, + ), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.key_outlined), + labelText: + AppLocalizations.of(context)!.oath_secret_key, + errorText: _validateSecretLength && !secretLengthValid + ? AppLocalizations.of(context)! + .oath_invalid_length + : null), + readOnly: _qrState == _QrScanState.success, + textInputAction: TextInputAction.done, + onChanged: (value) { + setState(() { + _validateSecretLength = false; + }); + }, + onSubmitted: (_) { + if (isValid) submit(); + }, ), - ChoiceFilterChip( - items: _digitsValues, - value: _digits, - selected: _digits != defaultDigits, - itemBuilder: (value) => Text( - '$value ${AppLocalizations.of(context)!.oath_digits}'), - onChanged: _qrState != _QrScanState.success - ? (digits) { - setState(() { - _digits = digits; - }); - } - : null, - ), - ], - ), - ] - .map((e) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: e, - )) - .toList(), - ), + if (qrScanner != null) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ActionChip( + avatar: _qrState != _QrScanState.scanning + ? (_qrState == _QrScanState.success + ? const Icon(Icons.qr_code) + : const Icon( + Icons.qr_code_scanner_outlined)) + : const CircularProgressIndicator( + strokeWidth: 2.0), + label: _qrState == _QrScanState.success + ? Text(AppLocalizations.of(context)! + .oath_scanned_qr) + : Text( + AppLocalizations.of(context)!.oath_scan_qr), + onPressed: () { + _scanQrCode(qrScanner); + }), + ), + const Divider(), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4.0, + runSpacing: 8.0, + children: [ + if (oathState?.version.isAtLeast(4, 2) ?? true) + FilterChip( + label: Text(AppLocalizations.of(context)! + .oath_require_touch), + selected: _touch, + onSelected: (value) { + setState(() { + _touch = value; + }); + }, + ), + ChoiceFilterChip( + items: OathType.values, + value: _oathType, + selected: _oathType != defaultOathType, + itemBuilder: (value) => Text(value.displayName), + onChanged: _qrState != _QrScanState.success + ? (value) { + setState(() { + _oathType = value; + }); + } + : null, + ), + ChoiceFilterChip( + items: hashAlgorithms, + value: _hashAlgorithm, + selected: _hashAlgorithm != defaultHashAlgorithm, + itemBuilder: (value) => Text(value.displayName), + onChanged: _qrState != _QrScanState.success + ? (value) { + setState(() { + _hashAlgorithm = value; + }); + } + : null, + ), + if (_oathType == OathType.totp) + ChoiceFilterChip( + items: _periodValues, + value: int.tryParse(_periodController.text) ?? + defaultPeriod, + selected: int.tryParse(_periodController.text) != + defaultPeriod, + itemBuilder: ((value) => Text( + '$value ${AppLocalizations.of(context)!.oath_sec}')), + onChanged: _qrState != _QrScanState.success + ? (period) { + setState(() { + _periodController.text = '$period'; + }); + } + : null, + ), + ChoiceFilterChip( + items: _digitsValues, + value: _digits, + selected: _digits != defaultDigits, + itemBuilder: (value) => Text( + '$value ${AppLocalizations.of(context)!.oath_digits}'), + onChanged: _qrState != _QrScanState.success + ? (digits) { + setState(() { + _digits = digits; + }); + } + : null, + ), + ], + ), + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ), ), ), ); diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 727365aa..49f658d0 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -21,6 +21,7 @@ import 'account_list.dart'; import 'add_account_page.dart'; import 'manage_password_dialog.dart'; import 'reset_dialog.dart'; +import 'unlock_form.dart'; class OathScreen extends ConsumerWidget { final DevicePath devicePath; @@ -80,7 +81,7 @@ class _LockedView extends ConsumerWidget { ], child: Column( children: [ - _UnlockForm( + UnlockForm( devicePath, keystore: oathState.keystore, ), @@ -244,122 +245,3 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { ]; } } - -class _UnlockForm extends ConsumerStatefulWidget { - final DevicePath _devicePath; - final KeystoreState keystore; - const _UnlockForm(this._devicePath, {required this.keystore}); - - @override - ConsumerState<_UnlockForm> createState() => _UnlockFormState(); -} - -class _UnlockFormState extends ConsumerState<_UnlockForm> { - final _passwordController = TextEditingController(); - bool _remember = false; - bool _passwordIsWrong = false; - bool _isObscure = true; - - void _submit() async { - setState(() { - _passwordIsWrong = false; - }); - final result = await ref - .read(oathStateProvider(widget._devicePath).notifier) - .unlock(_passwordController.text, remember: _remember); - if (!mounted) return; - if (!result.first) { - setState(() { - _passwordIsWrong = true; - _passwordController.clear(); - }); - } else if (_remember && !result.second) { - showMessage( - context, AppLocalizations.of(context)!.oath_failed_remember_pw); - } - } - - @override - Widget build(BuildContext context) { - final keystoreFailed = widget.keystore == KeystoreState.failed; - return Column( - children: [ - Padding( - padding: const EdgeInsets.only(left: 18.0, right: 18, top: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.oath_enter_oath_pw, - ), - const SizedBox(height: 16.0), - TextField( - key: keys.passwordField, - controller: _passwordController, - autofocus: true, - obscureText: _isObscure, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: AppLocalizations.of(context)!.oath_password, - errorText: _passwordIsWrong - ? AppLocalizations.of(context)!.oath_wrong_password - : null, - helperText: '', // Prevents resizing when errorText shown - prefixIcon: const Icon(Icons.password_outlined), - suffixIcon: IconButton( - icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off, - color: IconTheme.of(context).color, - ), - onPressed: () { - setState(() { - _isObscure = !_isObscure; - }); - }, - ), - ), - onChanged: (_) => setState(() { - _passwordIsWrong = false; - }), // Update state on change - onSubmitted: (_) => _submit(), - ), - ], - ), - ), - const SizedBox(height: 8.0), - keystoreFailed - ? ListTile( - leading: const Icon(Icons.warning_amber_rounded), - title: Text( - AppLocalizations.of(context)!.oath_keystore_unavailable), - dense: true, - minLeadingWidth: 0, - ) - : CheckboxListTile( - title: - Text(AppLocalizations.of(context)!.oath_remember_password), - dense: true, - controlAffinity: ListTileControlAffinity.leading, - value: _remember, - onChanged: (value) { - setState(() { - _remember = value ?? false; - }); - }, - ), - Padding( - padding: const EdgeInsets.only(top: 12.0, right: 18.0, bottom: 4.0), - child: Align( - alignment: Alignment.centerRight, - child: ElevatedButton.icon( - key: keys.unlockButton, - label: Text(AppLocalizations.of(context)!.oath_unlock), - icon: const Icon(Icons.lock_open), - onPressed: _passwordController.text.isNotEmpty ? _submit : null, - ), - ), - ), - ], - ); - } -} diff --git a/lib/oath/views/unlock_form.dart b/lib/oath/views/unlock_form.dart new file mode 100755 index 00000000..f02745c3 --- /dev/null +++ b/lib/oath/views/unlock_form.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/message.dart'; +import '../../app/models.dart'; +import '../models.dart'; +import '../keys.dart' as keys; +import '../state.dart'; + +class UnlockForm extends ConsumerStatefulWidget { + final DevicePath _devicePath; + final KeystoreState keystore; + const UnlockForm(this._devicePath, {required this.keystore, super.key}); + + @override + ConsumerState createState() => _UnlockFormState(); +} + +class _UnlockFormState extends ConsumerState { + final _passwordController = TextEditingController(); + bool _remember = false; + bool _passwordIsWrong = false; + bool _isObscure = true; + + void _submit() async { + setState(() { + _passwordIsWrong = false; + }); + final result = await ref + .read(oathStateProvider(widget._devicePath).notifier) + .unlock(_passwordController.text, remember: _remember); + if (!mounted) return; + if (!result.first) { + setState(() { + _passwordIsWrong = true; + _passwordController.clear(); + }); + } else if (_remember && !result.second) { + showMessage( + context, AppLocalizations.of(context)!.oath_failed_remember_pw); + } + } + + @override + Widget build(BuildContext context) { + final keystoreFailed = widget.keystore == KeystoreState.failed; + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 18.0, right: 18, top: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.oath_enter_oath_pw, + ), + const SizedBox(height: 16.0), + TextField( + key: keys.passwordField, + controller: _passwordController, + autofocus: true, + obscureText: _isObscure, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: AppLocalizations.of(context)!.oath_password, + errorText: _passwordIsWrong + ? AppLocalizations.of(context)!.oath_wrong_password + : null, + helperText: '', // Prevents resizing when errorText shown + prefixIcon: const Icon(Icons.password_outlined), + suffixIcon: IconButton( + icon: Icon( + _isObscure ? Icons.visibility : Icons.visibility_off, + color: IconTheme.of(context).color, + ), + onPressed: () { + setState(() { + _isObscure = !_isObscure; + }); + }, + ), + ), + onChanged: (_) => setState(() { + _passwordIsWrong = false; + }), // Update state on change + onSubmitted: (_) => _submit(), + ), + ], + ), + ), + const SizedBox(height: 8.0), + keystoreFailed + ? ListTile( + leading: const Icon(Icons.warning_amber_rounded), + title: Text( + AppLocalizations.of(context)!.oath_keystore_unavailable), + dense: true, + minLeadingWidth: 0, + ) + : CheckboxListTile( + title: + Text(AppLocalizations.of(context)!.oath_remember_password), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: _remember, + onChanged: (value) { + setState(() { + _remember = value ?? false; + }); + }, + ), + Padding( + padding: const EdgeInsets.only(top: 12.0, right: 18.0, bottom: 4.0), + child: Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + key: keys.unlockButton, + label: Text(AppLocalizations.of(context)!.oath_unlock), + icon: const Icon(Icons.lock_open), + onPressed: _passwordController.text.isNotEmpty ? _submit : null, + ), + ), + ), + ], + ); + } +}