diff --git a/lib/models/delete_account.dart b/lib/models/delete_account.dart new file mode 100644 index 000000000..e5c23f596 --- /dev/null +++ b/lib/models/delete_account.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; + +class DeleteChallengeResponse { + final bool allowDelete; + final String encryptedChallenge; + + DeleteChallengeResponse({ + @required this.allowDelete, + this.encryptedChallenge, + }); +} diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart index 1e0569045..1081ee352 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -10,6 +10,7 @@ import 'package:photos/core/network.dart'; import 'package:photos/db/public_keys_db.dart'; import 'package:photos/events/two_factor_status_change_event.dart'; import 'package:photos/events/user_details_changed_event.dart'; +import 'package:photos/models/delete_account.dart'; import 'package:photos/models/key_attributes.dart'; import 'package:photos/models/key_gen_result.dart'; import 'package:photos/models/public_key.dart'; @@ -209,6 +210,76 @@ class UserService { } } + Future getDeleteChallenge( + BuildContext context, + ) async { + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + final response = await _dio.get( + _config.getHttpEndpoint() + "/users/delete-challenge", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response != null && response.statusCode == 200) { + // clear data + await dialog.hide(); + return DeleteChallengeResponse( + allowDelete: response.data["allowDelete"] as bool, + encryptedChallenge: response.data["encryptedChallenge"], + ); + } else { + throw Exception("delete action failed"); + } + } catch (e) { + _logger.severe(e); + await dialog.hide(); + await showGenericErrorDialog(context); + return null; + } + } + + Future deleteAccount( + BuildContext context, + String challengeResponse, + ) async { + final dialog = createProgressDialog(context, "Deleting account..."); + await dialog.show(); + try { + final response = await _dio.delete( + _config.getHttpEndpoint() + "/users/delete", + data: { + "challenge": challengeResponse, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response != null && response.statusCode == 200) { + // clear data + await Configuration.instance.logout(); + await dialog.hide(); + showToast( + context, + "We have deleted your account and scheduled your uploaded data " + "for deletion.", + ); + Navigator.of(context).popUntil((route) => route.isFirst); + } else { + throw Exception("delete action failed"); + } + } catch (e) { + _logger.severe(e); + await dialog.hide(); + showGenericErrorDialog(context); + } + } + Future verifyEmail(BuildContext context, String ott) async { final dialog = createProgressDialog(context, "Please wait..."); await dialog.show(); diff --git a/lib/ui/account/delete_account_page.dart b/lib/ui/account/delete_account_page.dart new file mode 100644 index 000000000..30c99ae69 --- /dev/null +++ b/lib/ui/account/delete_account_page.dart @@ -0,0 +1,257 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_email_sender/flutter_email_sender.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/models/delete_account.dart'; +import 'package:photos/services/user_service.dart'; +import 'package:photos/ui/common/dialogs.dart'; +import 'package:photos/ui/common/gradient_button.dart'; +import 'package:photos/ui/tools/app_lock.dart'; +import 'package:photos/utils/auth_util.dart'; +import 'package:photos/utils/crypto_util.dart'; +import 'package:photos/utils/toast_util.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class DeleteAccountPage extends StatelessWidget { + const DeleteAccountPage({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + title: const Text("Delete account"), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + "💔", + style: TextStyle( + fontSize: 100, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + ), + Center( + child: Text( + "We'll be sorry to see you go. Are you facing some issue?", + style: Theme.of(context).textTheme.subtitle2, + ), + ), + const SizedBox( + height: 8, + ), + RichText( + // textAlign: TextAlign.center, + text: TextSpan( + children: const [ + TextSpan(text: "Please write to us at "), + TextSpan( + text: "feedback@ente.io", + style: TextStyle(color: Color.fromRGBO(29, 185, 84, 1)), + ), + TextSpan( + text: ", maybe there is a way we can help.", + ), + ], + style: Theme.of(context).textTheme.subtitle2, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + ), + GradientButton( + text: "Yes, send feedback", + paddingValue: 4, + iconData: Icons.check, + onTap: () async { + await launchUrl( + Uri( + scheme: "mailto", + path: 'feedback@ente.io', + ), + ); + }, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + ), + InkWell( + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + side: const BorderSide( + color: Colors.redAccent, + ), + padding: const EdgeInsets.symmetric( + vertical: 18, + horizontal: 10, + ), + backgroundColor: Colors.white, + ), + label: const Text( + "No, delete account", + style: TextStyle( + color: Colors.redAccent, // same for both themes + ), + textAlign: TextAlign.center, + ), + onPressed: () async => {await _initiateDelete(context)}, + icon: const Icon( + Icons.no_accounts, + color: Colors.redAccent, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Future _initiateDelete(BuildContext context) async { + AppLock.of(context).setEnabled(false); + String reason = "Please authenticate to initiate account deletion"; + final result = await requestAuthentication(reason); + AppLock.of(context).setEnabled( + Configuration.instance.shouldShowLockScreen(), + ); + if (!result) { + showToast(context, reason); + return; + } + final deleteChallengeResponse = + await UserService.instance.getDeleteChallenge(context); + if (deleteChallengeResponse == null) { + return; + } + if (deleteChallengeResponse.allowDelete) { + await _confirmAndDelete(context, deleteChallengeResponse); + } else { + await _requestEmailForDeletion(context); + } + } + + Future _confirmAndDelete( + BuildContext context, + DeleteChallengeResponse response, + ) async { + final choice = await showChoiceDialog( + context, + 'Are you sure you want to delete your account?', + 'Your uploaded data will be scheduled for deletion, and your account ' + 'will be permanently deleted. \n\nThis action is not reversible.', + firstAction: 'Cancel', + secondAction: 'Delete', + firstActionColor: Theme.of(context).colorScheme.onSurface, + secondActionColor: Colors.red, + ); + if (choice != DialogUserChoice.secondChoice) { + return; + } + final decryptChallenge = CryptoUtil.openSealSync( + Sodium.base642bin(response.encryptedChallenge), + Sodium.base642bin(Configuration.instance.getKeyAttributes().publicKey), + Configuration.instance.getSecretKey(), + ); + final challengeResponseStr = utf8.decode(decryptChallenge); + await UserService.instance.deleteAccount(context, challengeResponseStr); + } + + Future _requestEmailForDeletion(BuildContext context) async { + AlertDialog alert = AlertDialog( + title: const Text( + "Delete account", + style: TextStyle( + color: Colors.red, + ), + ), + content: RichText( + text: TextSpan( + children: [ + const TextSpan( + text: "Please send an email to ", + ), + TextSpan( + text: "account-deletion@ente.io", + style: TextStyle( + color: Colors.orange[300], + ), + ), + const TextSpan( + text: + " from your registered email address.\n\nYour request will be processed within 72 hours.", + ), + ], + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + height: 1.5, + fontSize: 16, + ), + ), + ), + actions: [ + TextButton( + child: const Text( + "Send email", + style: TextStyle( + color: Colors.red, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + try { + final Email email = Email( + recipients: ['account-deletion@ente.io'], + isHTML: false, + ); + await FlutterEmailSender.send(email); + } catch (e) { + launch("mailto:account-deletion@ente.io"); + } + }, + ), + TextButton( + child: Text( + "Ok", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + } +} diff --git a/lib/ui/account/ott_verification_page.dart b/lib/ui/account/ott_verification_page.dart index 70d071232..24dc3d262 100644 --- a/lib/ui/account/ott_verification_page.dart +++ b/lib/ui/account/ott_verification_page.dart @@ -159,7 +159,7 @@ class _OTTVerificationPageState extends State { controller: _verificationCodeController, autofocus: false, autocorrect: false, - keyboardType: TextInputType.visiblePassword, + keyboardType: TextInputType.number, onChanged: (_) { setState(() {}); }, diff --git a/lib/ui/grant_permissions_widget.dart b/lib/ui/grant_permissions_widget.dart index c945f33a6..283a3faea 100644 --- a/lib/ui/grant_permissions_widget.dart +++ b/lib/ui/grant_permissions_widget.dart @@ -12,67 +12,61 @@ class GrantPermissionsWidget extends StatelessWidget { MediaQuery.of(context).platformBrightness == Brightness.light; return Scaffold( body: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 100, 0, 50), - child: Stack( - alignment: Alignment.center, - children: [ - isLightMode - ? Image.asset( - 'assets/loading_photos_background.png', - color: Colors.white.withOpacity(0.4), - colorBlendMode: BlendMode.modulate, - ) - : Image.asset( - 'assets/loading_photos_background_dark.png', - ), - Center( - child: Column( - children: [ - const SizedBox(height: 42), - Image.asset( - "assets/gallery_locked.png", - height: 160, - ), - ], + child: Padding( + padding: const EdgeInsets.only(top: 20, bottom: 120), + child: Column( + children: [ + Center( + child: Stack( + alignment: Alignment.center, + children: [ + isLightMode + ? Image.asset( + 'assets/loading_photos_background.png', + color: Colors.white.withOpacity(0.4), + colorBlendMode: BlendMode.modulate, + ) + : Image.asset( + 'assets/loading_photos_background_dark.png', ), - ), - ], + Center( + child: Column( + children: [ + const SizedBox(height: 42), + Image.asset( + "assets/gallery_locked.png", + height: 160, + ), + ], + ), ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(40, 0, 40, 0), + child: RichText( + text: TextSpan( + style: Theme.of(context) + .textTheme + .headline5 + .copyWith(fontWeight: FontWeight.w700), + children: [ + const TextSpan(text: 'ente '), + TextSpan( + text: "needs permission to ", + style: Theme.of(context) + .textTheme + .headline5 + .copyWith(fontWeight: FontWeight.w400), + ), + const TextSpan(text: 'preserve your photos'), + ], ), ), - Padding( - padding: const EdgeInsets.fromLTRB(40, 0, 40, 105), - child: RichText( - text: TextSpan( - style: Theme.of(context) - .textTheme - .headline5 - .copyWith(fontWeight: FontWeight.w700), - children: [ - const TextSpan(text: 'ente '), - TextSpan( - text: "needs permission to ", - style: Theme.of(context) - .textTheme - .headline5 - .copyWith(fontWeight: FontWeight.w400), - ), - const TextSpan(text: 'preserve your photos'), - ], - ), - ), - ), - ], - ), - ], + ), + ], + ), ), ), floatingActionButton: Container( @@ -87,10 +81,10 @@ class GrantPermissionsWidget extends StatelessWidget { ], ), width: double.infinity, - padding: EdgeInsets.only( + padding: const EdgeInsets.only( left: 20, right: 20, - bottom: Platform.isIOS ? 40 : 16, + bottom: 16, ), child: OutlinedButton( child: const Text("Grant permission"), diff --git a/lib/ui/settings/danger_section_widget.dart b/lib/ui/settings/danger_section_widget.dart index c08d9eec5..a8e42ff1e 100644 --- a/lib/ui/settings/danger_section_widget.dart +++ b/lib/ui/settings/danger_section_widget.dart @@ -1,11 +1,11 @@ import 'package:expandable/expandable.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_email_sender/flutter_email_sender.dart'; import 'package:photos/services/user_service.dart'; +import 'package:photos/ui/account/delete_account_page.dart'; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/ui/settings/settings_section_title.dart'; import 'package:photos/ui/settings/settings_text_item.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:photos/utils/navigation_util.dart'; class DangerSectionWidget extends StatefulWidget { const DangerSectionWidget({Key key}) : super(key: key); @@ -39,8 +39,8 @@ class _DangerSectionWidgetState extends State { sectionOptionDivider, GestureDetector( behavior: HitTestBehavior.translucent, - onTap: () { - _onDeleteAccountTapped(); + onTap: () async { + routeToPage(context, const DeleteAccountPage()); }, child: const SettingsTextItem( text: "Delete account", @@ -51,81 +51,6 @@ class _DangerSectionWidgetState extends State { ); } - Future _onDeleteAccountTapped() async { - AlertDialog alert = AlertDialog( - title: const Text( - "Delete account", - style: TextStyle( - color: Colors.red, - ), - ), - content: RichText( - text: TextSpan( - children: [ - const TextSpan( - text: "Please send an email to ", - ), - TextSpan( - text: "account-deletion@ente.io", - style: TextStyle( - color: Colors.orange[300], - ), - ), - const TextSpan( - text: - " from your registered email address.\n\nYour request will be processed within 72 hours.", - ), - ], - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - height: 1.5, - fontSize: 16, - ), - ), - ), - actions: [ - TextButton( - child: const Text( - "Send email", - style: TextStyle( - color: Colors.red, - ), - ), - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop('dialog'); - try { - final Email email = Email( - recipients: ['account-deletion@ente.io'], - isHTML: false, - ); - await FlutterEmailSender.send(email); - } catch (e) { - launch("mailto:account-deletion@ente.io"); - } - }, - ), - TextButton( - child: Text( - "Ok", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop('dialog'); - }, - ), - ], - ); - - showDialog( - context: context, - builder: (BuildContext context) { - return alert; - }, - ); - } - Future _onLogoutTapped() async { AlertDialog alert = AlertDialog( title: const Text( diff --git a/pubspec.yaml b/pubspec.yaml index 26257a339..f66304c44 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: ente photos application # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.6.11+341 +version: 0.6.14+344 environment: sdk: ">=2.10.0 <3.0.0"