diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 5590c92fe..2f8e9f7ab 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -12,6 +12,7 @@ import "package:ente_auth/l10n/l10n.dart"; import 'package:ente_auth/locale.dart'; import "package:ente_auth/onboarding/view/onboarding_page.dart"; import 'package:ente_auth/services/update_service.dart'; +import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/ui/home_page.dart'; import 'package:ente_auth/ui/settings/app_update_dialog.dart'; import 'package:flutter/foundation.dart'; @@ -49,6 +50,7 @@ class _AppState extends State { } }); _signedInEvent = Bus.instance.on().listen((event) { + UserService.instance.getUserDetailsV2().ignore(); if (mounted) { setState(() {}); } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index dc34b7bdc..413773ab5 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -87,6 +87,8 @@ "importInstruction": "Please select a file that contains a list of your codes in the following format", "importCodeDelimiterInfo": "The codes can be separated by a comma or a new line", "selectFile": "Select file", + "emailVerificationToggle": "Email verification", + "authToChangeEmailVerificationSetting": "Please authenticate to change email verification", "authToViewYourRecoveryKey": "Please authenticate to view your recovery key", "authToChangeYourEmail": "Please authenticate to change your email", "authToChangeYourPassword": "Please authenticate to change your password", diff --git a/lib/models/api/user/srp.dart b/lib/models/api/user/srp.dart index 0bea9dbfb..baee0335f 100644 --- a/lib/models/api/user/srp.dart +++ b/lib/models/api/user/srp.dart @@ -87,6 +87,7 @@ class SrpAttributes { final int memLimit; final int opsLimit; final String kekSalt; + final bool isEmailMFAEnabled; SrpAttributes({ required this.srpUserID, @@ -94,6 +95,7 @@ class SrpAttributes { required this.memLimit, required this.opsLimit, required this.kekSalt, + required this.isEmailMFAEnabled, }); factory SrpAttributes.fromMap(Map map) { @@ -103,6 +105,7 @@ class SrpAttributes { memLimit: map['attributes']['memLimit'], opsLimit: map['attributes']['opsLimit'], kekSalt: map['attributes']['kekSalt'], + isEmailMFAEnabled: map['attributes']['isEmailMFAEnabled'], ); } } diff --git a/lib/models/user_details.dart b/lib/models/user_details.dart index 9533cb68a..e50d3ee29 100644 --- a/lib/models/user_details.dart +++ b/lib/models/user_details.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart'; @@ -10,6 +11,7 @@ class UserDetails { final int sharedCollectionsCount; final Subscription subscription; final FamilyData? familyData; + final ProfileData? profileData; UserDetails( this.email, @@ -18,6 +20,7 @@ class UserDetails { this.sharedCollectionsCount, this.subscription, this.familyData, + this.profileData, ); bool isPartOfFamily() { @@ -59,8 +62,10 @@ class UserDetails { (map['sharedCollectionsCount'] ?? 0) as int, Subscription.fromMap(map['subscription']), FamilyData.fromMap(map['familyData']), + ProfileData.fromJson(map['profileData']), ); } + } class FamilyMember { @@ -80,7 +85,39 @@ class FamilyMember { ); } } +class ProfileData { + bool canDisableEmailMFA; + bool isEmailMFAEnabled; + bool isTwoFactorEnabled; + // Constructor with default values + ProfileData({ + this.canDisableEmailMFA = false, + this.isEmailMFAEnabled = false, + this.isTwoFactorEnabled = false, + }); + + // Factory method to create ProfileData instance from JSON + factory ProfileData.fromJson(Map? json) { + if (json == null) null; + + return ProfileData( + canDisableEmailMFA: json!['canDisableEmailMFA'] ?? false, + isEmailMFAEnabled: json['isEmailMFAEnabled'] ?? false, + isTwoFactorEnabled: json['isTwoFactorEnabled'] ?? false, + ); + } + + // Method to convert ProfileData instance to JSON + Map toJson() { + return { + 'canDisableEmailMFA': canDisableEmailMFA, + 'isEmailMFAEnabled': isEmailMFAEnabled, + 'isTwoFactorEnabled': isTwoFactorEnabled, + }; + } + String toJsonString() => json.encode(toJson()); +} class FamilyData { final List? members; diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart index 4f4af1ae3..2900e7cae 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -45,6 +45,8 @@ import "package:uuid/uuid.dart"; class UserService { static const keyHasEnabledTwoFactor = "has_enabled_two_factor"; static const keyUserDetails = "user_details"; + static const kCanDisableEmailMFA = "can_disable_email_mfa"; + static const kIsEmailMFAEnabled = "is_email_mfa_enabled"; final SRP6GroupParameters kDefaultSrpGroup = SRP6StandardGroups.rfc5054_4096; final _dio = Network.instance.getDio(); final _enteDio = Network.instance.enteDio; @@ -131,10 +133,9 @@ class UserService { Future getUserDetailsV2({ - bool memoryCount = true, - bool shouldCache = false, + bool memoryCount = false, + bool shouldCache = true, }) async { - _logger.info("Fetching user details"); try { final response = await _enteDio.get( "/users/details/v2", @@ -144,14 +145,18 @@ class UserService { ); final userDetails = UserDetails.fromMap(response.data); if (shouldCache) { + if(userDetails.profileData != null) { + _preferences.setBool(kIsEmailMFAEnabled, userDetails.profileData!.isEmailMFAEnabled); + _preferences.setBool(kCanDisableEmailMFA, userDetails.profileData!.canDisableEmailMFA); + } // handle email change from different client if (userDetails.email != _config.getEmail()) { setEmail(userDetails.email); } } return userDetails; - } on DioError catch (e) { - _logger.info(e); + } catch(e) { + _logger.warning("Failed to fetch", e); rethrow; } } @@ -890,7 +895,26 @@ class UserService { } } + bool? canDisableEmailMFA() { + return _preferences.getBool(kCanDisableEmailMFA);; + } + bool hasEmailMFAEnabled() { + return _preferences.getBool(kIsEmailMFAEnabled) ?? true; + } - + Future updateEmailMFA(bool isEnabled) async { + try { + await _enteDio.put( + "/users/email-mfa", + data: { + "isEnabled": isEnabled, + }, + ); + _preferences.setBool(kIsEmailMFAEnabled, isEnabled); + } catch (e) { + _logger.severe("Failed to update email mfa",e); + rethrow; + } + } } diff --git a/lib/ui/account/login_page.dart b/lib/ui/account/login_page.dart index 3a5f37634..dfd3addd1 100644 --- a/lib/ui/account/login_page.dart +++ b/lib/ui/account/login_page.dart @@ -2,6 +2,7 @@ import 'package:email_validator/email_validator.dart'; import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/core/errors.dart'; import "package:ente_auth/l10n/l10n.dart"; +import 'package:ente_auth/models/api/user/srp.dart'; import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/ui/account/login_pwd_verification_page.dart'; import 'package:ente_auth/ui/common/dynamic_fab.dart'; @@ -61,22 +62,27 @@ class _LoginPageState extends State { buttonText: context.l10n.logInLabel, onPressedFunction: () async { UserService.instance.setEmail(_email!); + SrpAttributes? attr; + bool isEmailVerificationEnabled = true; try { - final attr = await UserService.instance.getSrpAttributes(_email!); + attr = await UserService.instance.getSrpAttributes(_email!); + isEmailVerificationEnabled = attr.isEmailMFAEnabled; + } catch (e) { + if (e is! SrpSetupNotCompleteError) { + _logger.severe('Error getting SRP attributes', e); + } + } + if (attr != null && !isEmailVerificationEnabled) { Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { return LoginPasswordVerificationPage( - srpAttributes: attr, + srpAttributes: attr!, ); }, ), ); - } - catch (e) { - if(e is! SrpSetupNotCompleteError) { - _logger.warning('Error getting SRP attributes', e); - } + } else { await UserService.instance .sendOtt(context, _email!, isCreateAccountScreen: false); } diff --git a/lib/ui/settings/security_section_widget.dart b/lib/ui/settings/security_section_widget.dart index 18b29c782..426d03851 100644 --- a/lib/ui/settings/security_section_widget.dart +++ b/lib/ui/settings/security_section_widget.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/account/sessions_page.dart'; import 'package:ente_auth/ui/components/captioned_text_widget.dart'; @@ -8,6 +11,7 @@ import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; import 'package:ente_auth/ui/components/menu_item_widget.dart'; import 'package:ente_auth/ui/components/toggle_switch_widget.dart'; import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/utils/toast_util.dart'; import 'package:flutter/material.dart'; class SecuritySectionWidget extends StatefulWidget { @@ -41,6 +45,15 @@ class _SecuritySectionWidgetState extends State { } Widget _getSectionOptions(BuildContext context) { + final bool? canDisableMFA = UserService.instance.canDisableEmailMFA(); + if (canDisableMFA == null) { + // We don't know if the user can disable MFA yet, so we fetch the info + UserService.instance.getUserDetailsV2().then( + (value) => { + if (mounted) {setState(() {})} + }, + ); + } final l10n = context.l10n; final List children = []; children.addAll([ @@ -64,6 +77,33 @@ class _SecuritySectionWidgetState extends State { }, ), ), + if(canDisableMFA ?? false) + sectionOptionSpacing, + if(canDisableMFA ?? false) + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.emailVerificationToggle, + ), + trailingWidget: ToggleSwitchWidget( + value: () => UserService.instance.hasEmailMFAEnabled(), + onChanged: () async { + final hasAuthenticated = await LocalAuthenticationService + .instance + .requestLocalAuthentication( + context, + l10n.authToChangeEmailVerificationSetting, + ); + final isEmailMFAEnabled = + UserService.instance.hasEmailMFAEnabled(); + if (hasAuthenticated) { + await updateEmailMFA(!isEmailMFAEnabled); + if(mounted){ + setState(() {}); + } + } + }, + ), + ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( @@ -95,4 +135,12 @@ class _SecuritySectionWidgetState extends State { children: children, ); } + + Future updateEmailMFA(bool isEnabled) async { + try { + await UserService.instance.updateEmailMFA(isEnabled); + } catch (e) { + showToast(context, context.l10n.somethingWentWrongMessage); + } + } } diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index d979377f4..74fd07eb4 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/theme/colors.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/settings/about_section_widget.dart'; @@ -20,8 +21,10 @@ class SettingsPage extends StatelessWidget { final ValueNotifier emailNotifier; const SettingsPage({Key? key, required this.emailNotifier}) : super(key: key); + @override Widget build(BuildContext context) { + UserService.instance.getUserDetailsV2().ignore(); final enteColorScheme = getEnteColorScheme(context); return Scaffold( body: Container( diff --git a/pubspec.yaml b/pubspec.yaml index d44a55bd0..bd1fd9d38 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ente_auth description: ente two-factor authenticator -version: 1.0.41+41 +version: 1.0.43+43 publish_to: none environment: