Add reusable two-factor verification for login and money operations (#32)
This commit is contained in:
1 parent
b7a58b1a87
commit
fb40f860d2
41 files changed
+2439
-43
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 =>
|
||||
'Срок проверки истёк. Вернитесь и начните снова.';
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "Срок проверки истёк. Вернитесь и начните снова."
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('/'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user