From e61ca489ceec5425f8868e4beacba29d27218996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Wed, 1 Feb 2023 12:22:17 +0000 Subject: [PATCH] [SG-834] Mobile pending login requests management screen (#2281) * Bootstrap new classes for settings list * [SG-834] Add new method GetActivePasswordlessLoginRequestsAsync to AuthService * [SG-834] Add generic handle exception method to BaseViewModel * [SG-834] Add request verification to settings entry * [SG-834] Add text resources * [SG-834] Update view and viewmodel * [SG-834] Remove unnecessary property assignment * [SG-834] removed logger resolve --- src/App/Controls/ExtendedCollectionView.cs | 13 +- .../Accounts/LoginPasswordlessViewModel.cs | 2 +- .../LoginPasswordlessRequestsListPage.xaml | 107 ++++++++++++++ .../LoginPasswordlessRequestsListPage.xaml.cs | 38 +++++ .../LoginPasswordlessRequestsListViewModel.cs | 139 ++++++++++++++++++ .../SettingsPage/SettingsPageViewModel.cs | 33 ++++- src/App/Resources/AppResources.Designer.cs | 45 ++++++ src/App/Resources/AppResources.resx | 15 ++ src/Core/Abstractions/IAuthService.cs | 1 + src/Core/Services/AuthService.cs | 7 + 10 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 src/App/Pages/Settings/LoginPasswordlessRequestsListPage.xaml create mode 100644 src/App/Pages/Settings/LoginPasswordlessRequestsListPage.xaml.cs create mode 100644 src/App/Pages/Settings/LoginPasswordlessRequestsListViewModel.cs diff --git a/src/App/Controls/ExtendedCollectionView.cs b/src/App/Controls/ExtendedCollectionView.cs index 79e1bac9d..0048630e6 100644 --- a/src/App/Controls/ExtendedCollectionView.cs +++ b/src/App/Controls/ExtendedCollectionView.cs @@ -1,4 +1,6 @@ -using Xamarin.Forms; +using System.Linq; +using Xamarin.CommunityToolkit.Converters; +using Xamarin.Forms; namespace Bit.App.Controls { @@ -6,4 +8,13 @@ namespace Bit.App.Controls { public string ExtraDataForLogging { get; set; } } + + public class SelectionChangedEventArgsConverter : BaseNullableConverterOneWay + { + public override object? ConvertFrom(SelectionChangedEventArgs? value) + { + return value?.CurrentSelection.FirstOrDefault(); + } + } + } diff --git a/src/App/Pages/Accounts/LoginPasswordlessViewModel.cs b/src/App/Pages/Accounts/LoginPasswordlessViewModel.cs index f795abe26..63eb299c8 100644 --- a/src/App/Pages/Accounts/LoginPasswordlessViewModel.cs +++ b/src/App/Pages/Accounts/LoginPasswordlessViewModel.cs @@ -118,7 +118,7 @@ namespace Bit.App.Pages } var loginRequestData = await _authService.GetPasswordlessLoginRequestByIdAsync(LoginRequest.Id); - if (loginRequestData.RequestApproved.HasValue && loginRequestData.ResponseDate.HasValue) + if (loginRequestData.IsAnswered) { await _platformUtilsService.ShowDialogAsync(AppResources.ThisRequestIsNoLongerValid); await Page.Navigation.PopModalAsync(); diff --git a/src/App/Pages/Settings/LoginPasswordlessRequestsListPage.xaml b/src/App/Pages/Settings/LoginPasswordlessRequestsListPage.xaml new file mode 100644 index 000000000..3a170c30a --- /dev/null +++ b/src/App/Pages/Settings/LoginPasswordlessRequestsListPage.xaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/Pages/Settings/LoginPasswordlessRequestsListPage.xaml.cs b/src/App/Pages/Settings/LoginPasswordlessRequestsListPage.xaml.cs new file mode 100644 index 000000000..6c9a5b449 --- /dev/null +++ b/src/App/Pages/Settings/LoginPasswordlessRequestsListPage.xaml.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Bit.Core.Abstractions; +using Bit.Core.Models.Response; +using Bit.Core.Utilities; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public partial class LoginPasswordlessRequestsListPage : BaseContentPage + { + private LoginPasswordlessRequestsListViewModel _vm; + + public LoginPasswordlessRequestsListPage() + { + InitializeComponent(); + SetActivityIndicator(_mainContent); + _vm = BindingContext as LoginPasswordlessRequestsListViewModel; + _vm.Page = this; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + await LoadOnAppearedAsync(_mainLayout, false, _vm.RefreshAsync, _mainContent); + } + + private async void Close_Clicked(object sender, System.EventArgs e) + { + if (DoOnce()) + { + await Navigation.PopModalAsync(); + } + } + } +} + diff --git a/src/App/Pages/Settings/LoginPasswordlessRequestsListViewModel.cs b/src/App/Pages/Settings/LoginPasswordlessRequestsListViewModel.cs new file mode 100644 index 000000000..d7e796b4f --- /dev/null +++ b/src/App/Pages/Settings/LoginPasswordlessRequestsListViewModel.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using Bit.App.Abstractions; +using Bit.App.Resources; +using Bit.Core.Abstractions; +using Bit.Core.Models.Response; +using Bit.Core.Utilities; +using Xamarin.CommunityToolkit.ObjectModel; +using Xamarin.Forms; + +namespace Bit.App.Pages +{ + public class LoginPasswordlessRequestsListViewModel : BaseViewModel + { + private readonly IAuthService _authService; + private readonly IStateService _stateService; + private readonly IDeviceActionService _deviceActionService; + private readonly IPlatformUtilsService _platformUtilsService; + private bool _isRefreshing; + + public LoginPasswordlessRequestsListViewModel() + { + _authService = ServiceContainer.Resolve(); + _stateService = ServiceContainer.Resolve(); + _deviceActionService = ServiceContainer.Resolve(); + _platformUtilsService = ServiceContainer.Resolve(); + + PageTitle = AppResources.PendingLogInRequests; + LoginRequests = new ObservableRangeCollection(); + + AnswerRequestCommand = new AsyncCommand(PasswordlessLoginAsync, + onException: ex => HandleException(ex), + allowsMultipleExecutions: false); + + DeclineAllRequestsCommand = new AsyncCommand(DeclineAllRequestsAsync, + onException: ex => HandleException(ex), + allowsMultipleExecutions: false); + + RefreshCommand = new Command(async () => await RefreshAsync()); + } + + public ICommand RefreshCommand { get; } + + public AsyncCommand AnswerRequestCommand { get; } + + public AsyncCommand DeclineAllRequestsCommand { get; } + + public ObservableRangeCollection LoginRequests { get; } + + public bool IsRefreshing + { + get => _isRefreshing; + set => SetProperty(ref _isRefreshing, value); + } + + public async Task RefreshAsync() + { + try + { + IsRefreshing = true; + LoginRequests.ReplaceRange(await _authService.GetActivePasswordlessLoginRequestsAsync()); + if (!LoginRequests.Any()) + { + Page.Navigation.PopModalAsync().FireAndForget(); + } + } + catch (Exception ex) + { + HandleException(ex); + } + finally + { + IsRefreshing = false; + } + } + + private async Task PasswordlessLoginAsync(PasswordlessLoginResponse request) + { + if (request.IsExpired) + { + await _platformUtilsService.ShowDialogAsync(AppResources.LoginRequestHasAlreadyExpired); + await Page.Navigation.PopModalAsync(); + return; + } + + var loginRequestData = await _authService.GetPasswordlessLoginRequestByIdAsync(request.Id); + if (loginRequestData.IsAnswered) + { + await _platformUtilsService.ShowDialogAsync(AppResources.ThisRequestIsNoLongerValid); + return; + } + + var page = new LoginPasswordlessPage(new LoginPasswordlessDetails() + { + PubKey = loginRequestData.PublicKey, + Id = loginRequestData.Id, + IpAddress = loginRequestData.RequestIpAddress, + Email = await _stateService.GetEmailAsync(), + FingerprintPhrase = loginRequestData.RequestFingerprint, + RequestDate = loginRequestData.CreationDate, + DeviceType = loginRequestData.RequestDeviceType, + Origin = loginRequestData.Origin + }); + + await Device.InvokeOnMainThreadAsync(() => Application.Current.MainPage.Navigation.PushModalAsync(new NavigationPage(page))); + } + + private async Task DeclineAllRequestsAsync() + { + try + { + if (!await _platformUtilsService.ShowDialogAsync(AppResources.AreYouSureYouWantToDeclineAllPendingLogInRequests, null, AppResources.Yes, AppResources.No)) + { + return; + } + + await _deviceActionService.ShowLoadingAsync(AppResources.Loading); + var taskList = new List(); + foreach (var request in LoginRequests) + { + taskList.Add(_authService.PasswordlessLoginAsync(request.Id, request.PublicKey, false)); + } + await Task.WhenAll(taskList); + await _deviceActionService.HideLoadingAsync(); + await RefreshAsync(); + _platformUtilsService.ShowToast("info", null, AppResources.RequestsDeclined); + } + catch (Exception ex) + { + HandleException(ex); + RefreshAsync().FireAndForget(); + } + } + } +} + diff --git a/src/App/Pages/Settings/SettingsPage/SettingsPageViewModel.cs b/src/App/Pages/Settings/SettingsPage/SettingsPageViewModel.cs index d6e348a64..ec49f9c42 100644 --- a/src/App/Pages/Settings/SettingsPage/SettingsPageViewModel.cs +++ b/src/App/Pages/Settings/SettingsPage/SettingsPageViewModel.cs @@ -9,6 +9,7 @@ using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Domain; +using Bit.Core.Models.Response; using Bit.Core.Models.View; using Bit.Core.Services; using Bit.Core.Utilities; @@ -35,6 +36,7 @@ namespace Bit.App.Pages private readonly IClipboardService _clipboardService; private readonly ILogger _loggerService; private readonly IPushNotificationService _pushNotificationService; + private readonly IAuthService _authService; private readonly IWatchDeviceService _watchDeviceService; private const int CustomVaultTimeoutValue = -100; @@ -49,7 +51,6 @@ namespace Bit.App.Pages private bool _reportLoggingEnabled; private bool _approvePasswordlessLoginRequests; private bool _shouldConnectToWatch; - private List> _vaultTimeouts = new List> { @@ -92,8 +93,8 @@ namespace Bit.App.Pages _clipboardService = ServiceContainer.Resolve("clipboardService"); _loggerService = ServiceContainer.Resolve("logger"); _pushNotificationService = ServiceContainer.Resolve(); + _authService = ServiceContainer.Resolve(); _watchDeviceService = ServiceContainer.Resolve(); - GroupedItems = new ObservableRangeCollection(); PageTitle = AppResources.Settings; @@ -144,7 +145,6 @@ namespace Bit.App.Pages !await _keyConnectorService.GetUsesKeyConnector(); _reportLoggingEnabled = await _loggerService.IsEnabled(); _approvePasswordlessLoginRequests = await _stateService.GetApprovePasswordlessLoginsAsync(); - _shouldConnectToWatch = await _stateService.GetShouldConnectToWatchAsync(); BuildList(); @@ -563,6 +563,14 @@ namespace Bit.App.Pages ExecuteAsync = () => TwoStepAsync() } }; + if (_approvePasswordlessLoginRequests) + { + manageItems.Add(new SettingsPageListItem + { + Name = AppResources.PendingLogInRequests, + ExecuteAsync = () => PendingLoginRequestsAsync() + }); + } if (_supportsBiometric || _biometric) { var biometricName = AppResources.Biometrics; @@ -754,6 +762,25 @@ namespace Bit.App.Pages } } + private async Task PendingLoginRequestsAsync() + { + try + { + var requests = await _authService.GetActivePasswordlessLoginRequestsAsync(); + if (requests == null || !requests.Any()) + { + _platformUtilsService.ShowToast("info", null, AppResources.NoPendingRequests); + return; + } + + Page.Navigation.PushModalAsync(new NavigationPage(new LoginPasswordlessRequestsListPage())).FireAndForget(); + } + catch (Exception ex) + { + HandleException(ex); + } + } + private bool IncludeLinksWithSubscriptionInfo() { if (Device.RuntimePlatform == Device.iOS) diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs index c5fbf0d3d..78e07d023 100644 --- a/src/App/Resources/AppResources.Designer.cs +++ b/src/App/Resources/AppResources.Designer.cs @@ -562,6 +562,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Are you sure you want to decline all pending login requests?. + /// + public static string AreYouSureYouWantToDeclineAllPendingLogInRequests { + get { + return ResourceManager.GetString("AreYouSureYouWantToDeclineAllPendingLogInRequests", resourceCulture); + } + } + /// /// Looks up a localized string similar to Are you sure you want to turn on screen capture?. /// @@ -1759,6 +1768,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Decline all requests. + /// + public static string DeclineAllRequests { + get { + return ResourceManager.GetString("DeclineAllRequests", resourceCulture); + } + } + /// /// Looks up a localized string similar to Default. /// @@ -4283,6 +4301,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to No pending requests. + /// + public static string NoPendingRequests { + get { + return ResourceManager.GetString("NoPendingRequests", resourceCulture); + } + } + /// /// Looks up a localized string similar to Nord. /// @@ -4770,6 +4797,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Pending login requests. + /// + public static string PendingLogInRequests { + get { + return ResourceManager.GetString("PendingLogInRequests", resourceCulture); + } + } + /// /// Looks up a localized string similar to An organization policy is affecting your ownership options.. /// @@ -5122,6 +5158,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Requests declined. + /// + public static string RequestsDeclined { + get { + return ResourceManager.GetString("RequestsDeclined", resourceCulture); + } + } + /// /// Looks up a localized string similar to Resend code. /// diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index 87e04009e..ab05adf09 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -2523,6 +2523,21 @@ Do you want to switch to this account? This request is no longer valid + + Pending login requests + + + Decline all requests + + + Are you sure you want to decline all pending login requests? + + + Requests declined + + + No pending requests + Enable camera permission to use the scanner diff --git a/src/Core/Abstractions/IAuthService.cs b/src/Core/Abstractions/IAuthService.cs index e1c947a9d..e26438743 100644 --- a/src/Core/Abstractions/IAuthService.cs +++ b/src/Core/Abstractions/IAuthService.cs @@ -30,6 +30,7 @@ namespace Bit.Core.Abstractions Task LogInPasswordlessAsync(string email, string accessCode, string authRequestId, byte[] decryptionKey, string userKeyCiphered, string localHashedPasswordCiphered); Task> GetPasswordlessLoginRequestsAsync(); + Task> GetActivePasswordlessLoginRequestsAsync(); Task GetPasswordlessLoginRequestByIdAsync(string id); Task GetPasswordlessLoginResponseAsync(string id, string accessCode); Task PasswordlessLoginAsync(string id, string pubKey, bool requestApproved); diff --git a/src/Core/Services/AuthService.cs b/src/Core/Services/AuthService.cs index 04525a188..3651529c8 100644 --- a/src/Core/Services/AuthService.cs +++ b/src/Core/Services/AuthService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using Bit.Core.Abstractions; @@ -494,6 +495,12 @@ namespace Bit.Core.Services return await _apiService.GetAuthRequestAsync(); } + public async Task> GetActivePasswordlessLoginRequestsAsync() + { + var requests = await GetPasswordlessLoginRequestsAsync(); + return requests.Where(r => !r.IsAnswered && !r.IsExpired).OrderByDescending(r => r.CreationDate).ToList(); + } + public async Task GetPasswordlessLoginRequestByIdAsync(string id) { return await _apiService.GetAuthRequestAsync(id);