Add reusable two-factor verification for login and money operations (#32)

This commit is contained in:
Stephan D committed 2026-09-12 12:44:34 +02:00
1 parent b7a58b1a87
commit fb40f860d2
41 files changed
+2439 -43

No files matched your search

+3
View File
@@ -23,6 +23,9 @@ class AuthFeedback extends StatelessWidget {
AuthError.emailInUse => l.authEmailInUse,
AuthError.unavailable => l.authUnavailable,
AuthError.unexpected => l.authUnexpected,
AuthError.factorLocked => l.factorLocked,
AuthError.factorCooldown => l.factorWait,
AuthError.factorRequired => l.factorRequired,
};
return Semantics(
liveRegion: true,
+25 -1
View File
@@ -9,6 +9,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../routing/routes.dart';
import '../security/challenge.dart';
import 'feedback.dart';
import 'validation.dart';
@@ -96,11 +97,34 @@ class _AuthFormState extends State<AuthForm> {
_confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email);
} else {
await context.read<LoginProvider>().login(
final login = context.read<LoginProvider>();
await login.login(
email: _email.text,
password: _password.text,
locale: locale,
);
if (!mounted || _signUp) return;
final challenge = login.state.result?.challenge;
if (challenge != null) {
_password.clear();
final grant = await verifyFactor(
context,
challenge: challenge,
service: context.read<TwoFactorService>(),
recorder: context.read<JourneyRecorder>(),
journey: JourneyKind.login,
);
if (!mounted || _signUp) return;
if (grant == null) {
login.reset();
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
return;
}
await login.complete(challenge, grant);
if (mounted && login.state.status == AuthStatus.success) {
context.go('/accounts');
}
}
}
}
@@ -1465,6 +1465,174 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Make another transaction'**
String get wAnother;
/// No description provided for @factorTitle.
///
/// In en, this message translates to:
/// **'One more check.'**
String get factorTitle;
/// No description provided for @factorLogin.
///
/// In en, this message translates to:
/// **'Confirm it’s you to finish signing in.'**
String get factorLogin;
/// No description provided for @factorMoney.
///
/// In en, this message translates to:
/// **'Confirm this transaction. No money moves before confirmation.'**
String get factorMoney;
/// No description provided for @factorEnableHelp.
///
/// In en, this message translates to:
/// **'Confirm to turn on two-factor verification for sign-in and money operations.'**
String get factorEnableHelp;
/// No description provided for @factorDisableHelp.
///
/// In en, this message translates to:
/// **'Confirm to turn off two-factor verification. Sign-in and money operations will no longer ask for a second code.'**
String get factorDisableHelp;
/// No description provided for @factorEmail.
///
/// In en, this message translates to:
/// **'Enter the six-digit code sent to your email.'**
String get factorEmail;
/// No description provided for @factorAuthenticator.
///
/// In en, this message translates to:
/// **'Enter the current six-digit code from your authenticator app.'**
String get factorAuthenticator;
/// No description provided for @factorCode.
///
/// In en, this message translates to:
/// **'Verification code'**
String get factorCode;
/// No description provided for @factorConfirm.
///
/// In en, this message translates to:
/// **'Verify and continue'**
String get factorConfirm;
/// No description provided for @factorResend.
///
/// In en, this message translates to:
/// **'Send a new code'**
String get factorResend;
/// No description provided for @factorHelp.
///
/// In en, this message translates to:
/// **'Check your inbox and spam folder. A new code replaces the previous one.'**
String get factorHelp;
/// No description provided for @factorAppHelp.
///
/// In en, this message translates to:
/// **'Use the current code. If you cannot access your authenticator, cancel this action.'**
String get factorAppHelp;
/// No description provided for @factorInvalid.
///
/// In en, this message translates to:
/// **'That code is not valid. Check all six digits and try again.'**
String get factorInvalid;
/// No description provided for @factorExpired.
///
/// In en, this message translates to:
/// **'This verification has expired. Request a new code or return to start again.'**
String get factorExpired;
/// No description provided for @factorLocked.
///
/// In en, this message translates to:
/// **'Too many incorrect attempts. Wait five minutes, then start verification again.'**
String get factorLocked;
/// No description provided for @factorWait.
///
/// In en, this message translates to:
/// **'Please wait before requesting another code.'**
String get factorWait;
/// No description provided for @factorRequired.
///
/// In en, this message translates to:
/// **'Verification is needed again. Please confirm this action once more.'**
String get factorRequired;
/// No description provided for @factorUnavailable.
///
/// In en, this message translates to:
/// **'Could not complete verification. Try again or cancel this action.'**
String get factorUnavailable;
/// No description provided for @factorCooldown.
///
/// In en, this message translates to:
/// **'Send a new code in {seconds}s'**
String factorCooldown(int seconds);
/// No description provided for @factorSettings.
///
/// In en, this message translates to:
/// **'Two-factor verification'**
String get factorSettings;
/// No description provided for @factorSettingsHelp.
///
/// In en, this message translates to:
/// **'Add a verification code when you sign in, top up, exchange or pay out.'**
String get factorSettingsHelp;
/// No description provided for @factorOn.
///
/// In en, this message translates to:
/// **'On'**
String get factorOn;
/// No description provided for @factorOff.
///
/// In en, this message translates to:
/// **'Off'**
String get factorOff;
/// No description provided for @factorEnable.
///
/// In en, this message translates to:
/// **'Turn on'**
String get factorEnable;
/// No description provided for @factorDisable.
///
/// In en, this message translates to:
/// **'Turn off'**
String get factorDisable;
/// No description provided for @factorEmailMethod.
///
/// In en, this message translates to:
/// **'Email code'**
String get factorEmailMethod;
/// No description provided for @factorAppMethod.
///
/// In en, this message translates to:
/// **'Authenticator app'**
String get factorAppMethod;
/// No description provided for @factorAppExpired.
///
/// In en, this message translates to:
/// **'This verification has expired. Return and start again.'**
String get factorAppExpired;
}
class _AppLocalizationsDelegate
@@ -758,4 +758,103 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get wAnother => 'Make another transaction';
@override
String get factorTitle => 'One more check.';
@override
String get factorLogin => 'Confirm it’s you to finish signing in.';
@override
String get factorMoney =>
'Confirm this transaction. No money moves before confirmation.';
@override
String get factorEnableHelp =>
'Confirm to turn on two-factor verification for sign-in and money operations.';
@override
String get factorDisableHelp =>
'Confirm to turn off two-factor verification. Sign-in and money operations will no longer ask for a second code.';
@override
String get factorEmail => 'Enter the six-digit code sent to your email.';
@override
String get factorAuthenticator =>
'Enter the current six-digit code from your authenticator app.';
@override
String get factorCode => 'Verification code';
@override
String get factorConfirm => 'Verify and continue';
@override
String get factorResend => 'Send a new code';
@override
String get factorHelp =>
'Check your inbox and spam folder. A new code replaces the previous one.';
@override
String get factorAppHelp =>
'Use the current code. If you cannot access your authenticator, cancel this action.';
@override
String get factorInvalid =>
'That code is not valid. Check all six digits and try again.';
@override
String get factorExpired =>
'This verification has expired. Request a new code or return to start again.';
@override
String get factorLocked =>
'Too many incorrect attempts. Wait five minutes, then start verification again.';
@override
String get factorWait => 'Please wait before requesting another code.';
@override
String get factorRequired =>
'Verification is needed again. Please confirm this action once more.';
@override
String get factorUnavailable =>
'Could not complete verification. Try again or cancel this action.';
@override
String factorCooldown(int seconds) {
return 'Send a new code in ${seconds}s';
}
@override
String get factorSettings => 'Two-factor verification';
@override
String get factorSettingsHelp =>
'Add a verification code when you sign in, top up, exchange or pay out.';
@override
String get factorOn => 'On';
@override
String get factorOff => 'Off';
@override
String get factorEnable => 'Turn on';
@override
String get factorDisable => 'Turn off';
@override
String get factorEmailMethod => 'Email code';
@override
String get factorAppMethod => 'Authenticator app';
@override
String get factorAppExpired =>
'This verification has expired. Return and start again.';
}
@@ -755,4 +755,103 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get wAnother => 'Создать новую операцию';
@override
String get factorTitle => 'Ещё одна проверка.';
@override
String get factorLogin => 'Подтвердите, что это вы, чтобы завершить вход.';
@override
String get factorMoney =>
'Подтвердите эту операцию. До подтверждения деньги не переводятся.';
@override
String get factorEnableHelp =>
'Подтвердите включение двухфакторной проверки для входа и операций с деньгами.';
@override
String get factorDisableHelp =>
'Подтвердите отключение двухфакторной проверки. Для входа и операций больше не потребуется второй код.';
@override
String get factorEmail => 'Введите шестизначный код из письма.';
@override
String get factorAuthenticator =>
'Введите текущий шестизначный код из приложения-аутентификатора.';
@override
String get factorCode => 'Код подтверждения';
@override
String get factorConfirm => 'Подтвердить и продолжить';
@override
String get factorResend => 'Отправить новый код';
@override
String get factorHelp =>
'Проверьте входящие и папку «Спам». Новый код заменяет предыдущий.';
@override
String get factorAppHelp =>
'Используйте текущий код. Если приложение недоступно, отмените это действие.';
@override
String get factorInvalid =>
'Неверный код. Проверьте все шесть цифр и попробуйте ещё раз.';
@override
String get factorExpired =>
'Срок проверки истёк. Запросите новый код или вернитесь и начните снова.';
@override
String get factorLocked =>
'Слишком много неверных попыток. Подождите пять минут и начните проверку снова.';
@override
String get factorWait => 'Подождите перед повторным запросом кода.';
@override
String get factorRequired =>
'Нужна повторная проверка. Подтвердите это действие ещё раз.';
@override
String get factorUnavailable =>
'Не удалось завершить проверку. Повторите попытку или отмените действие.';
@override
String factorCooldown(int seconds) {
return 'Новый код через $seconds с';
}
@override
String get factorSettings => 'Двухфакторная проверка';
@override
String get factorSettingsHelp =>
'Подтверждайте кодом вход, пополнение, обмен и вывод средств.';
@override
String get factorOn => 'Включена';
@override
String get factorOff => 'Выключена';
@override
String get factorEnable => 'Включить';
@override
String get factorDisable => 'Выключить';
@override
String get factorEmailMethod => 'Код из письма';
@override
String get factorAppMethod => 'Приложение-аутентификатор';
@override
String get factorAppExpired =>
'Срок проверки истёк. Вернитесь и начните снова.';
}
+36 -1
View File
@@ -227,5 +227,40 @@
"wLimitsScope": "Outgoing activity · EUR equivalent",
"wPendingHint": "Refresh to check the latest status. You can keep using your other accounts.",
"wFundsHeading": "Where is my money?",
"wAnother": "Make another transaction"
"wAnother": "Make another transaction",
"factorTitle": "One more check.",
"factorLogin": "Confirm it’s you to finish signing in.",
"factorMoney": "Confirm this transaction. No money moves before confirmation.",
"factorEnableHelp": "Confirm to turn on two-factor verification for sign-in and money operations.",
"factorDisableHelp": "Confirm to turn off two-factor verification. Sign-in and money operations will no longer ask for a second code.",
"factorEmail": "Enter the six-digit code sent to your email.",
"factorAuthenticator": "Enter the current six-digit code from your authenticator app.",
"factorCode": "Verification code",
"factorConfirm": "Verify and continue",
"factorResend": "Send a new code",
"factorHelp": "Check your inbox and spam folder. A new code replaces the previous one.",
"factorAppHelp": "Use the current code. If you cannot access your authenticator, cancel this action.",
"factorInvalid": "That code is not valid. Check all six digits and try again.",
"factorExpired": "This verification has expired. Request a new code or return to start again.",
"factorLocked": "Too many incorrect attempts. Wait five minutes, then start verification again.",
"factorWait": "Please wait before requesting another code.",
"factorRequired": "Verification is needed again. Please confirm this action once more.",
"factorUnavailable": "Could not complete verification. Try again or cancel this action.",
"factorCooldown": "Send a new code in {seconds}s",
"factorSettings": "Two-factor verification",
"factorSettingsHelp": "Add a verification code when you sign in, top up, exchange or pay out.",
"factorOn": "On",
"factorOff": "Off",
"factorEnable": "Turn on",
"factorDisable": "Turn off",
"@factorCooldown": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"factorEmailMethod": "Email code",
"factorAppMethod": "Authenticator app",
"factorAppExpired": "This verification has expired. Return and start again."
}
+36 -1
View File
@@ -227,5 +227,40 @@
"wLimitsScope": "Исходящие операции · эквивалент в EUR",
"wPendingHint": "Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.",
"wFundsHeading": "Где мои деньги?",
"wAnother": "Создать новую операцию"
"wAnother": "Создать новую операцию",
"factorTitle": "Ещё одна проверка.",
"factorLogin": "Подтвердите, что это вы, чтобы завершить вход.",
"factorMoney": "Подтвердите эту операцию. До подтверждения деньги не переводятся.",
"factorEnableHelp": "Подтвердите включение двухфакторной проверки для входа и операций с деньгами.",
"factorDisableHelp": "Подтвердите отключение двухфакторной проверки. Для входа и операций больше не потребуется второй код.",
"factorEmail": "Введите шестизначный код из письма.",
"factorAuthenticator": "Введите текущий шестизначный код из приложения-аутентификатора.",
"factorCode": "Код подтверждения",
"factorConfirm": "Подтвердить и продолжить",
"factorResend": "Отправить новый код",
"factorHelp": "Проверьте входящие и папку «Спам». Новый код заменяет предыдущий.",
"factorAppHelp": "Используйте текущий код. Если приложение недоступно, отмените это действие.",
"factorInvalid": "Неверный код. Проверьте все шесть цифр и попробуйте ещё раз.",
"factorExpired": "Срок проверки истёк. Запросите новый код или вернитесь и начните снова.",
"factorLocked": "Слишком много неверных попыток. Подождите пять минут и начните проверку снова.",
"factorWait": "Подождите перед повторным запросом кода.",
"factorRequired": "Нужна повторная проверка. Подтвердите это действие ещё раз.",
"factorUnavailable": "Не удалось завершить проверку. Повторите попытку или отмените действие.",
"factorCooldown": "Новый код через {seconds} с",
"factorSettings": "Двухфакторная проверка",
"factorSettingsHelp": "Подтверждайте кодом вход, пополнение, обмен и вывод средств.",
"factorOn": "Включена",
"factorOff": "Выключена",
"factorEnable": "Включить",
"factorDisable": "Выключить",
"@factorCooldown": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"factorEmailMethod": "Код из письма",
"factorAppMethod": "Приложение-аутентификатор",
"factorAppExpired": "Срок проверки истёк. Вернитесь и начните снова."
}
+19 -4
View File
@@ -31,6 +31,7 @@ class SxchngeApp extends StatefulWidget {
this.identityService,
this.limitsService,
this.walletService,
this.twoFactorService,
this.photoService = const FilePhotoService(),
this.loginService = const DummyLoginService(),
this.signupService = const DummySignupService(),
@@ -41,6 +42,7 @@ class SxchngeApp extends StatefulWidget {
final IdentityService? identityService;
final LimitsService? limitsService;
final WalletService? walletService;
final TwoFactorService? twoFactorService;
final PhotoService photoService;
final LoginService loginService;
final SignupService signupService;
@@ -54,11 +56,21 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider();
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
late final _factors = widget.twoFactorService ?? DummyTwoFactorService();
late final _security = SecurityProvider(_factors, _analytics);
late final _identity = widget.identityService ?? DummyIdentityService();
late final _walletService =
widget.walletService ??
DummyWalletService(_identity, initialLocale: _locale.locale.languageCode);
late final _wallet = WalletProvider(_walletService, _analytics);
DummyWalletService(
_identity,
initialLocale: _locale.locale.languageCode,
factors: _factors,
);
late final _wallet = WalletProvider(
_walletService,
_analytics,
factors: _factors,
);
late final _limits =
widget.limitsService ??
(_walletService is DummyWalletService
@@ -67,7 +79,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
late final _journeys = JourneyRecorder(_analytics);
late final _services = InstrumentedAuthServices(
recorder: _journeys,
loginService: widget.loginService,
loginService: GuardedLoginService(widget.loginService, _factors),
signupService: widget.signupService,
verificationService: widget.verificationService,
);
@@ -83,7 +95,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
void _routeChanged() {
final path = _router.routeInformationProvider.value.uri.path;
if (path == '/' && _previousPath != '/') {
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
_journeys.abandon(JourneyExit.navigation);
}
_previousPath = path;
@@ -95,6 +107,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
_router.dispose();
_journeys.dispose();
_wallet.dispose();
_security.dispose();
_locale.dispose();
super.dispose();
}
@@ -103,6 +116,8 @@ class _SxchngeAppState extends State<SxchngeApp> {
Widget build(BuildContext context) => MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _wallet),
ChangeNotifierProvider.value(value: _security),
Provider<TwoFactorService>.value(value: _factors),
Provider<JourneyRecorder>.value(value: _journeys),
Provider<AnalyticsService>.value(value: _analytics),
Provider<IdentityService>.value(value: _identity),
+11
View File
@@ -8,6 +8,7 @@ import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../auth/page.dart';
import '../kyc/page.dart';
import '../security/challenge.dart';
import '../verification/page.dart';
import '../workspace/accounts.dart';
import '../workspace/activity.dart';
@@ -27,6 +28,16 @@ GoRouter createAppRouter() {
// Signup email is an in-memory navigation extra, never browser-history data.
extraCodec: const _TransientExtras(),
routes: [
GoRoute(
path: '/two-factor',
redirect: (context, state) =>
state.extra is FactorRouteRequest ? null : '/',
builder: (context, state) =>
FactorRoute(request: state.extra as FactorRouteRequest),
onExit: (context, state) =>
state.extra is! FactorRouteRequest ||
!(state.extra as FactorRouteRequest).provider.busy,
),
ShellRoute(
builder: (context, state, child) =>
WorkspaceShell(path: state.uri.path, child: child),
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../generated/i18n/app_localizations.dart';
class FactorRouteRequest {
const FactorRouteRequest(this.provider, this.summary);
final TwoFactorProvider provider;
final Widget? summary;
}
Future<FactorGrant?> verifyFactor(
BuildContext context, {
required FactorChallenge challenge,
required TwoFactorService service,
required JourneyRecorder recorder,
required JourneyKind journey,
Widget? summary,
}) async {
final provider = TwoFactorProvider(
service,
challenge,
recorder: recorder,
journey: journey,
);
FactorGrant? result;
try {
result = await context.push<FactorGrant>(
'/two-factor',
extra: FactorRouteRequest(provider, summary),
);
return result;
} finally {
if (result == null) {
final step = recorder.begin(
journey,
JourneyStep.factorVerification,
simulated: service.isSimulated,
);
recorder.finish(step, cancelled: true, simulated: service.isSimulated);
}
provider.dispose();
}
}
String factorErrorText(AppLocalizations l, FactorError error) =>
switch (error) {
FactorError.invalid => l.factorInvalid,
FactorError.expired => l.factorExpired,
FactorError.locked => l.factorLocked,
FactorError.cooldown => l.factorWait,
FactorError.required => l.factorRequired,
FactorError.unavailable => l.factorUnavailable,
};
class FactorRoute extends StatefulWidget {
const FactorRoute({super.key, required this.request});
final FactorRouteRequest request;
@override
State<FactorRoute> createState() => _FactorRouteState();
}
class _FactorRouteState extends State<FactorRoute> {
FactorRouteRequest get request => widget.request;
@override
void dispose() {
request.provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final purpose = request.provider.challenge.purpose;
final purposeText = switch (purpose) {
FactorPurpose.login => l.factorLogin,
FactorPurpose.enable => l.factorEnableHelp,
FactorPurpose.disable => l.factorDisableHelp,
_ => l.factorMoney,
};
final methodText = request.provider.challenge.method == FactorMethod.email
? l.factorEmail
: l.factorAuthenticator;
return TwoFactorPage(
provider: request.provider,
summary: request.summary,
labels: TwoFactorLabels(
title: l.factorTitle,
description: '$purposeText $methodText',
code: l.factorCode,
confirm: l.factorConfirm,
cancel: l.wClose,
resend: l.factorResend,
expired: request.provider.challenge.method == FactorMethod.email
? l.factorExpired
: l.factorAppExpired,
help: request.provider.challenge.method == FactorMethod.email
? l.factorHelp
: l.factorAppHelp,
errorText: (error) =>
error == FactorError.expired &&
request.provider.challenge.method == FactorMethod.authenticator
? l.factorAppExpired
: factorErrorText(l, error),
cooldownText: (seconds) => l.factorCooldown(seconds),
),
onVerified: (grant) => context.pop(grant),
onCancel: () => context.pop(),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'challenge.dart';
import '../generated/i18n/app_localizations.dart';
class SecuritySettings extends StatefulWidget {
const SecuritySettings({super.key});
@override
State<SecuritySettings> createState() => _SecuritySettingsState();
}
class _SecuritySettingsState extends State<SecuritySettings> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<SecurityProvider>().refresh();
});
}
Future<void> _change(SecurityProvider p, bool enabled) async {
final challenge = await p.prepare(enabled);
if (!mounted || challenge == null) return;
final grant = await verifyFactor(
context,
challenge: challenge,
service: p.service,
recorder: p.recorder,
journey: JourneyKind.security,
);
if (mounted && grant != null) await p.apply(enabled, grant);
}
@override
Widget build(BuildContext context) {
final p = context.watch<SecurityProvider>();
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.factorSettings, style: t.textTheme.titleLarge),
const SizedBox(height: 12),
Text(l.factorSettingsHelp),
const SizedBox(height: 16),
if (p.busy) const LinearProgressIndicator(),
if (p.data != null) ...[
Semantics(
liveRegion: true,
child: Text(
p.data!.enabled ? l.factorOn : l.factorOff,
key: const ValueKey('two-factor-status'),
style: t.textTheme.titleMedium,
),
),
const SizedBox(height: 8),
Text(
p.data!.method == FactorMethod.email
? l.factorEmailMethod
: l.factorAppMethod,
),
const SizedBox(height: 16),
FilledButton.tonal(
key: const ValueKey('two-factor-setting'),
onPressed: p.busy ? null : () => _change(p, !p.data!.enabled),
child: Text(p.data!.enabled ? l.factorDisable : l.factorEnable),
),
],
if (p.error != null) ...[
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
factorErrorText(l, p.error!),
style: TextStyle(color: t.colorScheme.error),
),
),
TextButton(
onPressed: p.busy ? null : p.refresh,
child: Text(l.wRefresh),
),
],
],
),
),
);
}
}
+2 -13
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../providers/locale.dart';
import '../security/settings.dart';
import '../generated/i18n/app_localizations.dart';
@@ -105,19 +106,7 @@ class SettingsPage extends StatelessWidget {
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.wSecurity, style: t.textTheme.titleLarge),
const SizedBox(height: 12),
Text(l.wSecurityBody),
],
),
),
),
const SecuritySettings(),
const SizedBox(height: 20),
TextButton(
onPressed: () => context.go('/'),
+35 -1
View File
@@ -6,8 +6,12 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../security/challenge.dart';
import 'copy.dart';
import 'summary.dart';
@@ -64,6 +68,25 @@ class TransferPageState extends State<TransferPage> {
}
}
Future<void> _confirm() async {
final p = _provider;
final challenge = await p.challenge();
if (!mounted || p.factorError != null || p.error != null) return;
FactorGrant? grant;
if (challenge != null) {
grant = await verifyFactor(
context,
challenge: challenge,
service: p.wallet.factors!,
recorder: p.recorder,
journey: p.journey,
summary: TransferSummary(quote: p.quote!),
);
if (!mounted || grant == null) return;
}
await p.submit(approval: grant);
}
Future<bool> allowExit() async {
if (_provider.result != null) return true;
if (_provider.busy || _provider.uncertain) return false;
@@ -163,6 +186,17 @@ class TransferPageState extends State<TransferPage> {
),
),
),
if (p.factorError != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Semantics(
liveRegion: true,
child: Text(
factorErrorText(l, p.factorError!),
style: TextStyle(color: t.colorScheme.error),
),
),
),
if (p.noSubmission) Text(l.wNoSubmission),
if (p.uncertain) ...[
Text(l.wUncertain),
@@ -183,7 +217,7 @@ class TransferPageState extends State<TransferPage> {
else
FilledButton(
key: const ValueKey('transfer-confirm'),
onPressed: p.busy ? null : p.submit,
onPressed: p.busy ? null : _confirm,
child: Text(l.wConfirm),
),
TextButton(