2023-11-09 16:09:59 +03:00
|
|
|
/*
|
|
|
|
* Copyright (C) 2023 Yubico.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import 'package:yubico_authenticator/app/logging.dart';
|
2023-11-23 18:25:11 +03:00
|
|
|
import 'package:yubico_authenticator/core/models.dart';
|
2023-11-09 16:09:59 +03:00
|
|
|
import 'package:yubico_authenticator/core/state.dart';
|
|
|
|
import 'package:yubico_authenticator/oath/models.dart';
|
|
|
|
import 'package:logging/logging.dart';
|
|
|
|
|
|
|
|
import '../../app/message.dart';
|
|
|
|
import '../../app/models.dart';
|
|
|
|
import '../../app/state.dart';
|
|
|
|
import '../../widgets/choice_filter_chip.dart';
|
|
|
|
import '../../widgets/responsive_dialog.dart';
|
|
|
|
import '../models.dart';
|
|
|
|
import '../state.dart';
|
|
|
|
import '../keys.dart' as keys;
|
|
|
|
import 'overwrite_confirm_dialog.dart';
|
|
|
|
|
|
|
|
final _log = Logger('otp.view.configure_hotp_dialog');
|
|
|
|
|
|
|
|
class ConfigureHotpDialog extends ConsumerStatefulWidget {
|
|
|
|
final DevicePath devicePath;
|
|
|
|
final OtpSlot otpSlot;
|
|
|
|
const ConfigureHotpDialog(this.devicePath, this.otpSlot, {super.key});
|
|
|
|
|
|
|
|
@override
|
|
|
|
ConsumerState<ConsumerStatefulWidget> createState() =>
|
|
|
|
_ConfigureHotpDialogState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _ConfigureHotpDialogState extends ConsumerState<ConfigureHotpDialog> {
|
2023-11-17 15:02:51 +03:00
|
|
|
final _secretController = TextEditingController();
|
|
|
|
bool _validateSecretLength = false;
|
2023-11-23 18:25:11 +03:00
|
|
|
bool _validateSecretFormat = false;
|
2023-11-09 16:09:59 +03:00
|
|
|
int _digits = defaultDigits;
|
|
|
|
final List<int> _digitsValues = [6, 8];
|
|
|
|
bool _appendEnter = true;
|
|
|
|
bool _isObscure = true;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
2023-11-17 15:02:51 +03:00
|
|
|
_secretController.dispose();
|
2023-11-09 16:09:59 +03:00
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
2023-11-17 15:02:51 +03:00
|
|
|
final secret = _secretController.text.replaceAll(' ', '');
|
|
|
|
final secretLengthValid = secret.isNotEmpty && secret.length * 5 % 8 < 5;
|
2023-11-23 18:25:11 +03:00
|
|
|
final secretFormatValid = Format.base32.isValid(secret);
|
2023-11-09 16:09:59 +03:00
|
|
|
|
|
|
|
return ResponsiveDialog(
|
|
|
|
title: Text(l10n.s_hotp),
|
|
|
|
actions: [
|
|
|
|
TextButton(
|
|
|
|
key: keys.saveButton,
|
2023-11-17 15:02:51 +03:00
|
|
|
onPressed: !_validateSecretLength
|
|
|
|
? () async {
|
|
|
|
if (!secretLengthValid) {
|
2023-11-09 16:09:59 +03:00
|
|
|
setState(() {
|
2023-11-17 15:02:51 +03:00
|
|
|
_validateSecretLength = true;
|
2023-11-09 16:09:59 +03:00
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
2023-11-23 18:25:11 +03:00
|
|
|
if (!secretFormatValid) {
|
|
|
|
setState(() {
|
|
|
|
_validateSecretFormat = true;
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
2023-11-09 16:09:59 +03:00
|
|
|
|
|
|
|
if (!await confirmOverwrite(context, widget.otpSlot)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
final otpNotifier =
|
|
|
|
ref.read(otpStateProvider(widget.devicePath).notifier);
|
|
|
|
try {
|
|
|
|
await otpNotifier.configureSlot(widget.otpSlot.slot,
|
|
|
|
configuration: SlotConfiguration.hotp(
|
|
|
|
key: secret,
|
|
|
|
options: SlotConfigurationOptions(
|
|
|
|
digits8: _digits == 8,
|
|
|
|
appendCr: _appendEnter)));
|
|
|
|
await ref.read(withContextProvider)((context) async {
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
showMessage(context,
|
2023-11-23 18:25:11 +03:00
|
|
|
l10n.l_slot_credential_configured(l10n.s_hotp));
|
2023-11-09 16:09:59 +03:00
|
|
|
});
|
|
|
|
} catch (e) {
|
|
|
|
_log.error('Failed to program credential', e);
|
|
|
|
await ref.read(withContextProvider)((context) async {
|
|
|
|
showMessage(
|
|
|
|
context,
|
|
|
|
l10n.p_otp_slot_configuration_error(
|
|
|
|
widget.otpSlot.slot.getDisplayName(l10n)),
|
|
|
|
duration: const Duration(seconds: 4),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
}
|
2023-11-17 15:02:51 +03:00
|
|
|
}
|
|
|
|
: null,
|
2023-11-09 16:09:59 +03:00
|
|
|
child: Text(l10n.s_save),
|
|
|
|
)
|
|
|
|
],
|
|
|
|
child: Padding(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
TextField(
|
|
|
|
key: keys.secretField,
|
2023-11-17 15:02:51 +03:00
|
|
|
controller: _secretController,
|
2023-11-09 16:09:59 +03:00
|
|
|
obscureText: _isObscure,
|
|
|
|
autofillHints: isAndroid ? [] : const [AutofillHints.password],
|
|
|
|
decoration: InputDecoration(
|
2023-11-23 18:25:11 +03:00
|
|
|
suffixIcon: Wrap(
|
|
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
|
|
children: [
|
|
|
|
IconButton(
|
|
|
|
icon: Icon(
|
|
|
|
_isObscure
|
|
|
|
? Icons.visibility
|
|
|
|
: Icons.visibility_off,
|
|
|
|
color: !(_validateSecretLength ||
|
|
|
|
_validateSecretFormat)
|
|
|
|
? IconTheme.of(context).color
|
|
|
|
: null),
|
|
|
|
onPressed: () {
|
|
|
|
setState(() {
|
|
|
|
_isObscure = !_isObscure;
|
|
|
|
});
|
|
|
|
},
|
|
|
|
),
|
|
|
|
if (_validateSecretLength || _validateSecretFormat) ...[
|
|
|
|
const Icon(Icons.error_outlined),
|
|
|
|
const SizedBox(
|
|
|
|
width: 8.0,
|
|
|
|
)
|
|
|
|
]
|
|
|
|
],
|
2023-11-09 16:09:59 +03:00
|
|
|
),
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
prefixIcon: const Icon(Icons.key_outlined),
|
|
|
|
labelText: l10n.s_secret_key,
|
2023-11-23 18:25:11 +03:00
|
|
|
helperText: '', // Prevents resizing when errorText shown
|
2023-11-17 15:02:51 +03:00
|
|
|
errorText: _validateSecretLength && !secretLengthValid
|
|
|
|
? l10n.s_invalid_length
|
2023-11-23 18:25:11 +03:00
|
|
|
: _validateSecretFormat && !secretFormatValid
|
|
|
|
? l10n.l_invalid_format_allowed_chars(
|
|
|
|
Format.base32.allowedCharacters)
|
|
|
|
: null),
|
2023-11-09 16:09:59 +03:00
|
|
|
textInputAction: TextInputAction.next,
|
|
|
|
onChanged: (value) {
|
|
|
|
setState(() {
|
2023-11-17 15:02:51 +03:00
|
|
|
_validateSecretLength = false;
|
2023-11-23 18:25:11 +03:00
|
|
|
_validateSecretFormat = false;
|
2023-11-09 16:09:59 +03:00
|
|
|
});
|
|
|
|
},
|
|
|
|
),
|
|
|
|
Wrap(
|
|
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
|
|
spacing: 4.0,
|
|
|
|
runSpacing: 8.0,
|
|
|
|
children: [
|
2023-11-23 18:25:11 +03:00
|
|
|
FilterChip(
|
|
|
|
label: Text(l10n.s_append_enter),
|
|
|
|
tooltip: l10n.l_append_enter_desc,
|
|
|
|
selected: _appendEnter,
|
|
|
|
onSelected: (value) {
|
|
|
|
setState(() {
|
|
|
|
_appendEnter = value;
|
|
|
|
});
|
|
|
|
},
|
|
|
|
),
|
2023-11-09 16:09:59 +03:00
|
|
|
ChoiceFilterChip<int>(
|
|
|
|
items: _digitsValues,
|
|
|
|
value: _digits,
|
|
|
|
selected: _digits != defaultDigits,
|
|
|
|
itemBuilder: (value) => Text(l10n.s_num_digits(value)),
|
|
|
|
onChanged: (digits) {
|
|
|
|
setState(() {
|
|
|
|
_digits = digits;
|
|
|
|
});
|
|
|
|
}),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
]
|
|
|
|
.map((e) => Padding(
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
|
|
child: e,
|
|
|
|
))
|
|
|
|
.toList(),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|