import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:yubico_authenticator/cancellation_exception.dart'; import 'package:yubico_authenticator/app/logging.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../desktop/models.dart'; import '../../widgets/file_drop_target.dart'; import '../../widgets/responsive_dialog.dart'; import '../models.dart'; import '../state.dart'; import 'utils.dart'; final _log = Logger('oath.view.add_account_page'); final _secretFormatterPattern = RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false); enum _QrScanState { none, scanning, success, failed } class OathAddAccountPage extends ConsumerStatefulWidget { final DevicePath devicePath; final OathState state; final bool openQrScanner; const OathAddAccountPage(this.devicePath, this.state, {super.key, required this.openQrScanner}); @override ConsumerState createState() => _OathAddAccountPageState(); } class _OathAddAccountPageState extends ConsumerState { final _issuerController = TextEditingController(); final _accountController = TextEditingController(); final _secretController = TextEditingController(); final _periodController = TextEditingController(text: '$defaultPeriod'); bool _touch = false; OathType _oathType = defaultOathType; HashAlgorithm _hashAlgorithm = defaultHashAlgorithm; int _digits = defaultDigits; bool _validateSecretLength = false; _QrScanState _qrState = _QrScanState.none; bool _isObscure = true; List _periodValues = [20, 30, 45, 60]; List _digitsValues = [6, 8]; _scanQrCode(QrScanner qrScanner) async { try { setState(() { _qrState = _QrScanState.scanning; }); final otpauth = await qrScanner.scanQr(); if (otpauth == null) { if (!mounted) return; showMessage(context, 'No QR code found'); setState(() { _qrState = _QrScanState.failed; }); } else { final data = CredentialData.fromUri(Uri.parse(otpauth)); _loadCredentialData(data); } } catch (e) { final String errorMessage; // TODO: Make this cleaner than importing desktop specific RpcError. if (e is RpcError) { errorMessage = e.message; } else { errorMessage = e.toString(); } showMessage( context, 'Failed reading QR code: $errorMessage', duration: const Duration(seconds: 4), ); setState(() { _qrState = _QrScanState.failed; }); } } _loadCredentialData(CredentialData data) { setState(() { _issuerController.text = data.issuer ?? ''; _accountController.text = data.name; _secretController.text = data.secret; _oathType = data.oathType; _hashAlgorithm = data.hashAlgorithm; _periodValues = [data.period]; _periodController.text = '${data.period}'; _digitsValues = [data.digits]; _digits = data.digits; _isObscure = true; _qrState = _QrScanState.success; }); } @override void initState() { super.initState(); final qrScanner = ref.read(qrScannerProvider); if (qrScanner != null && widget.openQrScanner) { WidgetsBinding.instance.addPostFrameCallback((_) { _scanQrCode(qrScanner); }); } } @override Widget build(BuildContext context) { final period = int.tryParse(_periodController.text) ?? -1; final remaining = getRemainingKeySpace( oathType: _oathType, period: period, issuer: _issuerController.text, name: _accountController.text, ); final issuerRemaining = remaining.first; final nameRemaining = remaining.second; final secret = _secretController.text.replaceAll(' ', ''); final secretLengthValid = secret.length * 5 % 8 < 5; final isValid = _accountController.text.isNotEmpty && secret.isNotEmpty && issuerRemaining >= -1 && nameRemaining >= 0 && period > 0; final qrScanner = ref.watch(qrScannerProvider); void submit() async { 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, ); try { await ref .read(credentialListProvider(widget.devicePath).notifier) .addAccount(cred.toUri(), requireTouch: _touch); if (!mounted) return; Navigator.of(context).pop(); showMessage(context, 'Account added'); } on CancellationException catch (_) { // ignored } catch (e) { _log.error('Failed to add account', e); final String errorMessage; // TODO: Make this cleaner than importing desktop specific RpcError. if (e is RpcError) { errorMessage = e.message; } else { errorMessage = e.toString(); } showMessage( context, 'Failed adding account: $errorMessage', duration: const Duration(seconds: 4), ); } } else { setState(() { _validateSecretLength = true; }); } } return ResponsiveDialog( title: const Text('Add account'), actions: [ TextButton( onPressed: isValid ? submit : null, child: const Text('Save', key: Key('save_btn')), ), ], child: FileDropTarget( onFileDropped: (fileData) async { if (qrScanner != null) { final b64Image = base64Encode(fileData); final otpauth = await qrScanner.scanQr(b64Image); if (otpauth == null) { if (!mounted) return; showMessage(context, 'No QR code found'); } else { final data = CredentialData.fromUri(Uri.parse(otpauth)); _loadCredentialData(data); } } }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( key: const Key('issuer'), 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 prefixIcon: Icon(Icons.business_outlined), ), onChanged: (value) { setState(() { // Update maxlengths }); }, onSubmitted: (_) { if (isValid) submit(); }, ), TextField( key: const Key('name'), controller: _accountController, maxLength: max(nameRemaining, 1), decoration: const InputDecoration( border: OutlineInputBorder(), labelText: 'Account name', helperText: '', // Prevents dialog resizing when enabled = false prefixIcon: Icon(Icons.person_outline), ), onChanged: (value) { setState(() { // Update maxlengths }); }, onSubmitted: (_) { if (isValid) submit(); }, ), TextField( key: const Key('secret'), controller: _secretController, obscureText: _isObscure, inputFormatters: [ FilteringTextInputFormatter.allow(_secretFormatterPattern) ], decoration: InputDecoration( suffixIcon: IconButton( icon: Icon( _isObscure ? Icons.visibility : Icons.visibility_off, ), onPressed: () { setState(() { _isObscure = !_isObscure; }); }, ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.key_outlined), labelText: 'Secret key', errorText: _validateSecretLength && !secretLengthValid ? 'Invalid length' : null), readOnly: _qrState == _QrScanState.success, onChanged: (value) { setState(() { _validateSecretLength = false; }); }, onSubmitted: (_) { if (isValid) submit(); }, ), if (qrScanner != null) Padding( padding: const EdgeInsets.only(top: 16.0), child: Row( children: [ if (_qrState != _QrScanState.scanning) ...[ OutlinedButton.icon( style: OutlinedButton.styleFrom( fixedSize: const Size.fromWidth(132)), onPressed: () { _scanQrCode(qrScanner); }, icon: const Icon(Icons.qr_code), label: const Text('Scan QR code'), ) ] else ...[ OutlinedButton( style: OutlinedButton.styleFrom( fixedSize: const Size.fromWidth(132)), onPressed: null, child: const SizedBox.square( dimension: 16.0, child: CircularProgressIndicator()), ) ] ], ), ), const Divider(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, runSpacing: 8.0, children: [ if (widget.state.version.isAtLeast(4, 2)) FilterChip( label: const Text('Require touch'), selected: _touch, onSelected: (value) { setState(() { _touch = value; }); }, ), Chip( backgroundColor: ChipTheme.of(context).selectedColor, label: DropdownButtonHideUnderline( child: DropdownButton( value: _oathType, isDense: true, underline: null, items: OathType.values .map((e) => DropdownMenuItem( value: e, child: Text(e.displayName), )) .toList(), onChanged: _qrState != _QrScanState.success ? (type) { setState(() { _oathType = type ?? OathType.totp; }); } : null, ), ), ), Chip( backgroundColor: ChipTheme.of(context).selectedColor, label: DropdownButtonHideUnderline( child: DropdownButton( value: _hashAlgorithm, isDense: true, underline: null, items: HashAlgorithm.values .where((alg) => alg != HashAlgorithm.sha512 || widget.state.version.isAtLeast(4, 3, 1)) .map((e) => DropdownMenuItem( value: e, child: Text(e.displayName), )) .toList(), onChanged: _qrState != _QrScanState.success ? (type) { setState(() { _hashAlgorithm = type ?? HashAlgorithm.sha1; }); } : null, ), ), ), if (_oathType == OathType.totp) Chip( backgroundColor: ChipTheme.of(context).selectedColor, label: DropdownButtonHideUnderline( child: DropdownButton( value: int.tryParse(_periodController.text) ?? defaultPeriod, isDense: true, underline: null, items: _periodValues .map((e) => DropdownMenuItem( value: e, child: Text('$e sec'), )) .toList(), onChanged: _qrState != _QrScanState.success ? (period) { setState(() { _periodController.text = '${period ?? defaultPeriod}'; }); } : null, ), ), ), Chip( backgroundColor: ChipTheme.of(context).selectedColor, label: DropdownButtonHideUnderline( child: DropdownButton( value: _digits, isDense: true, underline: null, items: _digitsValues .map((e) => DropdownMenuItem( value: e, child: Text('$e digits'), )) .toList(), onChanged: _qrState != _QrScanState.success ? (digits) { setState(() { _digits = digits ?? defaultDigits; }); } : null, ), ), ), ], ), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: e, )) .toList(), ), ), ); } }